diff --git a/.devcontainer/docker/devcontainer.json b/.devcontainer/docker/devcontainer.json index 6169d9172..cce1e89e2 100644 --- a/.devcontainer/docker/devcontainer.json +++ b/.devcontainer/docker/devcontainer.json @@ -8,7 +8,8 @@ } }, "mounts": [ - "source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached" + "source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached", + "source=${localEnv:HOME}/.gnupg,target=/home/vscode/.gnupg,type=bind,consistency=cached" ], "onCreateCommand": ".devcontainer/install-deps.sh", "customizations": { diff --git a/.devcontainer/install-deps.sh b/.devcontainer/install-deps.sh index e0d0ba87b..f72e75c71 100755 --- a/.devcontainer/install-deps.sh +++ b/.devcontainer/install-deps.sh @@ -38,6 +38,12 @@ APT_PACKAGES=( zstd python3-venv python3-kconfiglib + ripgrep + ca-certificates + curl + gnupg + nodejs + npm ) if [ "${ARCH}" = "amd64" ]; then @@ -61,3 +67,20 @@ wget https://github.com/jetkvm/rv1106-system/releases/download/${BUILDKIT_VERSIO rm buildkit.tar.zst popd rm -rf "${BUILDKIT_TMPDIR}" + +# Playwright Chromium system libraries (libnspr4, libnss3, libgbm, X11/xcb, etc.) +# Needed for `make test_e2e` / `npx playwright test` to launch the bundled headless shell. +sudo env "PATH=$PATH" npm exec --yes playwright@latest install-deps chromium + +# Docker CLI +sudo install -m 0755 -d /etc/apt/keyrings +sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc +sudo chmod a+r /etc/apt/keyrings/docker.asc + +echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \ + trixie stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + +sudo apt-get update && \ + sudo apt-get install -y docker-ce-cli && \ + sudo rm -rf /var/lib/apt/lists/* diff --git a/.devcontainer/podman/devcontainer.json b/.devcontainer/podman/devcontainer.json index 039ada420..c2a7390a3 100644 --- a/.devcontainer/podman/devcontainer.json +++ b/.devcontainer/podman/devcontainer.json @@ -17,7 +17,8 @@ "HOME": "/home/vscode" }, "mounts": [ - "source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached" + "source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached", + "source=${localEnv:HOME}/.gnupg,target=/home/vscode/.gnupg,type=bind,consistency=cached" ], "onCreateCommand": ".devcontainer/install-deps.sh", "customizations": { diff --git a/.github/ISSUE_TEMPLATE/keyboard-layout.yml b/.github/ISSUE_TEMPLATE/keyboard-layout.yml new file mode 100644 index 000000000..9f1405332 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/keyboard-layout.yml @@ -0,0 +1,141 @@ +name: Keyboard Layout +type: 'Feature' +description: Submit a new keyboard layout, fix an existing one, or request one we don't ship. +labels: +- 'type: keyboard-layout' +body: + - type: markdown + attributes: + value: | + Thanks for helping us cover more keyboard layouts! + + ## Are you ready to open a PR? + + **If yes**, please follow the step-by-step contributor walkthrough — it covers everything from picking a starting point to validating against `kbdlayout.info`: + + 👉 **[docs/keyboard/ADDING_A_LAYOUT.md](https://github.com/jetkvm/kvm/blob/dev/docs/keyboard/ADDING_A_LAYOUT.md)** + + A PR is the fastest path: you drop a single `.kle.json` into `internal/keyboard/layouts/`, the test suite tells you if anything is off, and we can merge it directly. + + **If no** (you don't code Go, you can't run the build, or you're just flagging a bug), keep filling in this issue. The fields below give us enough to do the work for you. + + --- + + ## What we need + + - The **locale code** and **display name** (always). + - At least one of: a `kbdlayout.info` URL **or** a KLE JSON pasted below. + - Anything you know about **dead keys** on this layout. + + - type: input + id: locale + attributes: + label: Locale code + description: | + Use `language-REGION` with a hyphen, e.g. `ko-KR`, `tr-TR`, `pt-BR`, `el-GR`. + Lowercased language, uppercased region. + placeholder: "e.g. ko-KR" + validations: + required: true + + - type: input + id: layout-name + attributes: + label: Display name + description: | + What the dropdown will show. Convention: native script + locale code + form factor — + e.g. "한국어 ko-KR (ANSI 103)", "Türkçe tr-TR (ISO 105)", "Português (Brasil) pt-BR (ABNT2)". + placeholder: 'e.g. 한국어 ko-KR (ANSI 103)' + validations: + required: true + + - type: dropdown + id: layout-type + attributes: + label: Physical layout type + description: | + ANSI is the flat single-row Enter (US standard). + ISO is the L-shaped Enter (most of Europe). + JIS is the Japanese variant with extra keys around the spacebar. + options: + - ISO 105-key (most European/international keyboards) + - ANSI 104-key (US standard) + - JIS 109-key (Japanese) + - Other (describe in Notes) + validations: + required: true + + - type: input + id: kbdlayout-url + attributes: + label: kbdlayout.info URL (preferred) + description: | + If your layout exists at [kbdlayout.info](https://kbdlayout.info/), paste the URL here. + This is the **most reliable** way to give us what we need — the page has the canonical + legends and dead-key behaviour, and our audit script can validate against it directly. + placeholder: "https://kbdlayout.info/00000412/" + validations: + required: false + + - type: textarea + id: kle-json + attributes: + label: KLE JSON (alternative if no kbdlayout.info URL) + description: | + If your layout isn't on kbdlayout.info, paste a KLE JSON here. You can: + + - Download one from [kbdlayout.info](https://kbdlayout.info/) for the closest match and edit it, or + - Build one on [keyboard-layout-editor.com](https://www.keyboard-layout-editor.com) and copy the **Raw data** tab. + + If you can run Go locally, please validate first: + + ```bash + go run scripts/validate_layout.go path/to/your-layout.kle.json + ``` + render: json + validations: + required: false + + - type: textarea + id: dead-keys + attributes: + label: Dead keys + description: | + A **dead key** is one that doesn't type a character on its own — pressing it puts + the keyboard in a "waiting" state, and the next key combines to produce an accented + character (e.g. on French AZERTY, `^` then `a` produces `â`). + + Paste the literal characters that act as dead keys on this layout, separated by + spaces — e.g. `^ ¨ ´ ` ~`. If the layout has no dead keys, write `none`. + + **Verify on a real machine running this layout** — some characters look like + accents but type immediately on a given layout. + placeholder: "^ ¨ ´ ` ~ (or 'none')" + validations: + required: true + + - type: textarea + id: notes + attributes: + label: Additional notes + description: | + Anything we should know — regional variants, AltGr-layer quirks, keys that + differ from kbdlayout.info, alternative locale codes that should alias to + this layout (e.g. `nl-BE` aliasing to `fr-BE`), unusual physical layouts. + validations: + required: false + + - type: checkboxes + attributes: + label: Checklist + options: + - label: The locale code matches `language-REGION` (hyphen, lowercase language, uppercase region). + required: true + - label: I have access to a real machine running this layout (or the kbdlayout.info reference) and have verified the legends. + required: true + - label: The dead-keys list reflects actual dead-key behaviour on the target OS, not just keys that look like accents. + required: true + - label: 'I have read [ADDING_A_LAYOUT.md](https://github.com/jetkvm/kvm/blob/dev/docs/keyboard/ADDING_A_LAYOUT.md) (optional but recommended — many questions are answered there).' + required: false + - label: 'I have run `go run scripts/validate_layout.go` against my KLE JSON (optional, only if you have Go installed).' + required: false diff --git a/.github/workflows/keyboard.yml b/.github/workflows/keyboard.yml new file mode 100644 index 000000000..cc01499b2 --- /dev/null +++ b/.github/workflows/keyboard.yml @@ -0,0 +1,86 @@ +name: keyboard +# Validates keyboard layouts whenever the parser, the layout files, the +# scripts that reason about them, or the docs that reference them change. +# Skipped on unrelated PRs to keep CI fast. + +on: + push: + branches: [dev, main] + paths: + - 'internal/keyboard/layouts/**' # built-in layout files (KLE JSON) + - 'internal/keyboard/testdata/**' # KLE fixtures used by unit tests + - 'internal/keyboard/keyaliases.json' # special-key taxonomy + - 'internal/keyboard/**.go' # parser, validator, tests, helpers + - 'scripts/audit_layouts.go' # the audit tool itself + - 'scripts/validate_layout.go' # the single-file validator + - '.github/workflows/keyboard.yml' # this workflow + pull_request: + paths: + - 'internal/keyboard/layouts/**' + - 'internal/keyboard/testdata/**' + - 'internal/keyboard/keyaliases.json' + - 'internal/keyboard/**.go' + - 'scripts/audit_layouts.go' + - 'scripts/validate_layout.go' + - '.github/workflows/keyboard.yml' + +permissions: + contents: read + +jobs: + layouts: + name: Layouts (unit tests + kbdlayout.info audit) + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-go@v6 + with: + go-version: "^1.25.1" + cache: true + + # Cache the kbdlayout.info reference downloads. The audit script + # writes them to whatever --cache points at; persist that across + # runs so we only hit kbdlayout.info on first-time misses. + - name: Restore kbdlayout.info cache + id: kbdcache + uses: actions/cache@v5 + with: + path: ~/.cache/kbdlayout-audit + key: kbdlayout-audit-${{ hashFiles('internal/keyboard/layouts/*.kle.json') }} + restore-keys: | + kbdlayout-audit- + + - name: Unit tests (parser, charMap, drift guards) + run: go test -v -count=1 ./internal/keyboard/... + + # Audit the locally-parsed layouts against the canonical + # kbdlayout.info reference. The script exits 1 only on real + # regressions (charMap entries missing on this side); known + # ISO/ANSI / dead-key differences are emitted as warnings and + # don't fail the job. + - name: Audit against kbdlayout.info + run: | + mkdir -p ~/.cache/kbdlayout-audit + go run ./scripts/audit_layouts.go --cache ~/.cache/kbdlayout-audit | tee audit-output.txt + + - name: Summarise audit + if: always() + run: | + { + echo "## Layout audit" + echo + echo '```' + tail -n 25 audit-output.txt 2>/dev/null || echo "(no audit output)" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload audit log + if: always() + uses: actions/upload-artifact@v4 + with: + name: layout-audit + path: audit-output.txt + if-no-files-found: ignore + retention-days: 14 diff --git a/.gitignore b/.gitignore index 64a08345c..9336c84b7 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,20 @@ ui/reports # compiled remote-agent test binary e2e/remote-agent/remote-agent + +# compiled keyboard-layouts test binary +audit-layouts + +# build artifacts +build/.cmake/ +build/_deps/ +build/CMakeFiles/ +build/CMakeCache.txt +ui/tsconfig.node.tsbuildinfo + +# devcontainer lock file +.devcontainer/docker/devcontainer-lock.json + +ui/localization/jetKVM.UI.inlang/cache/ +ui/localization/jetKVM.UI.inlang/README.md +ui/localization/jetKVM.UI.inlang/.meta.json diff --git a/.golangci.yml b/.golangci.yml index dd8a07940..a3e885213 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -26,6 +26,9 @@ linters: - linters: - forbidigo path: cmd/main.go + - linters: + - forbidigo + path: scripts/audit_layouts.go - linters: - gochecknoinits path: internal/logging/sse.go diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 5b5024f5b..fa9a095d1 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -106,6 +106,8 @@ tail -f /userdata/jetkvm/last.log ├── internal/ # Internal Go packages │ ├── confparser/ # Configuration file implementation │ ├── hidrpc/ # HIDRPC implementation for HID devices (keyboard, mouse, etc.) +│ ├── keyboard/ # KLE keyboard layout parser, built-in layouts, RPC handlers +│ │ └── layouts/ # Built-in KLE JSON files (ANSI/ISO/JIS) │ ├── logging/ # Logging implementation │ ├── mdns/ # mDNS implementation │ ├── native/ # CGO / Native code glue layer (on-device hardware) @@ -134,7 +136,6 @@ tail -f /userdata/jetkvm/last.log ├── assets/ # UI in-page images ├── components/ # UI components ├── hooks/ # Hooks (stores, RPC handling, virtual devices) - ├── keyboardLayouts/ # Keyboard layout definitions ├── paraglide/ # (localization compiled messages output) ├── providers/ # Feature flags └── routes/ # Pages (login, settings, etc.) @@ -142,14 +143,87 @@ tail -f /userdata/jetkvm/last.log **Key files for beginners:** -- `web.go` - Add new API endpoints here -- `config.go` - Add new settings here +- `jsonrpc.go` - JSON-RPC method registry. Most "I need the frontend to call something new on the device" changes go here. +- `web.go` - HTTP/REST + WebSocket endpoints (Gin). WebRTC signalling lives in `webrtc.go`. +- `config.go` - Add new settings here. Stored as `/userdata/kvm_config.json` on the device. - `tailscale.go` - Tailscale status and control-server logic - `ui/src/routes/` - Add new pages here - `ui/src/components/` - Add new UI components here --- +## Architecture Pointers + +A few things you'd otherwise have to grep for: + +### The binary runs as one of three processes + +`cmd/main.go` is the entry point for *every* JetKVM process. The same binary takes on a different role depending on environment variables: + +1. **Supervisor** (default invocation): forks the same binary as a child, restarts it on crash, captures dumps to `supervisor.ErrorDumpDir`. The first `jetkvm_app` you start is always the supervisor. +2. **Main app** (`EnvChildID` matches the built version): runs `kvm.Main()` — the WebRTC, HTTP, JSON-RPC, USB, etc. Most code in the repo runs in this mode. +3. **Native subprocess** (`subcomponent=native` flag, or `EnvSubcomponent` env var): runs `native.RunNativeProcess()`. The CGO-heavy HDMI capture and LVGL touchscreen process is isolated here so a native crash doesn't kill the main app. + +When debugging a crash, `/userdata/jetkvm/last.log` may contain output from any of the three. The supervisor crash dumps live alongside. + +### Internal package purposes + +A short tour of what's in `internal/`: + +| Package | Purpose | +|---|---| +| `confparser` | Configuration file parser shared between subsystems | +| `diagnostics` | System diagnostics dump used on supervisor crash | +| `hidrpc` | Binary HID protocol bridge between the main app and the native subprocess | +| `keyboard` | KLE keyboard layout parser + built-in layouts (`go:embed` from `layouts/`); user uploads stored at `/userdata/kvm_layouts/` on the device. See [docs/keyboard/](docs/keyboard/) for the full design and contributor guide. | +| `logging` | zerolog-based structured logging with subsystem scopes | +| `mdns` | mDNS service announcement | +| `native` | CGO bridge to the C native library (HDMI, touchscreen via LVGL). The `cgo/ui` symlink targets `../eez/src/ui` (EEZ Studio output). | +| `network` / `nmlite` (in `pkg/`) | Network configuration | +| `ota` | Over-the-air updates with GPG signature verification | +| `supervisor` | Constants and env-var names shared between the supervisor parent and the child processes | +| `sync` | `sync.Mutex` wrappers with optional trace logging (enabled by the `synctrace` build tag) | +| `tailscale` | Tailscale status + control-server logic | +| `timesync` | NTP/time sync | +| `tzdata` | Timezone data | +| `usbgadget` | USB gadget driver (keyboard, mouse, mass storage) | +| `utils` | SSH handling and other small utilities | +| `websecure` | TLS certificate management | + +### Build tags + +The default `make build_dev` and `make build_release` use `-tags netgo,timetzdata,nomsgpack`. Adding `-tags synctrace` (via `./dev_deploy.sh -r --enable-sync-trace`) enables verbose mutex logging via the `internal/sync` wrappers — useful for debugging deadlocks, expensive in normal runs. + +--- + +## Linting Rules to Know Before You Hit the Hook + +The `lint-staged` pre-commit hook is strict. Knowing the project rules ahead of time saves a lot of "fix and retry" cycles: + +### Go (`.golangci.yml`) + +- **`forbidigo`: no `fmt.Print*` or `log.*`** anywhere except `cmd/main.go` and the audit script. Use `internal/logging` instead — `logging.GetSubsystemLogger("name")` returns a zerolog logger. +- **`gochecknoinits`: no `func init()`**. The exception is `internal/logging/sse.go`. If you have setup code that must run at package load, use `var x = computeX()` instead — runs at the same point and produces the same result. +- **Formatter**: `goimports`. Editor integration handles this automatically. + +### TypeScript (`.oxlintrc.json`) + +- **Linter**: `oxlint`, **not** ESLint. Configured rules include `typescript-eslint/restrict-template-expressions` and `typescript-eslint/no-base-to-string` — beware of dropping `unknown` or `Event` straight into a template literal (use `String(e)` or `ev.type`). +- **Plugins enabled**: `react`, `typescript`, `import`. +- **Unused vars must be prefixed with `_`** (`argsIgnorePattern: ^_`). +- **Pre-commit hook** (husky/lint-staged): + - `oxlint --fix --deny-warnings` on staged `.ts/.tsx` + - `oxfmt` on staged `.ts/.tsx/.js/.jsx/.css/.md` + - `i18n:resort` on staged `localization/messages/*.json` + +If a hook failure is in code you didn't touch, see if it's tripping on a pre-existing warning the file picked up; the hook lints the whole file, not just your diff. + +### PR target + +PRs go to **`dev`**, not `main`. The Makefile's `dev_release` target enforces this for releases; PRs that target the wrong branch are usually retargeted on review. + +--- + ## Development Modes ### Full Development (Recommended) @@ -590,6 +664,40 @@ If you enable the [Sherlock](https://inlang.com/m/r7kp499g/app-inlang-ideExtensi - Using [inlang CLI](https://inlang.com/m/2qj2w8pu/app-inlang-cli) to support the npm commands. - You can install the [Sherlock VS Code extension](https://marketplace.visualstudio.com/items?itemName=inlang.vs-code-extension) in your devcontainer. +### Keyboard Layouts + +The virtual keyboard and paste-text system are driven by [KLE](https://keyboard-layout-editor.com) JSON files parsed on the Go backend. Built-in layouts live in `internal/keyboard/layouts/` and are embedded into the binary via `go:embed`. + +#### How it works + +1. Each `.kle.json` file describes a physical keyboard layout (key positions, sizes, legends per layer) +2. The Go parser (`internal/keyboard/keyboard.go`) processes KLE JSON into a `KeyboardLayout` struct: + - Infers USB HID scancodes from key positions + - Builds a `charMap` mapping characters to scancode + modifier combinations + - Auto-generates uppercase shift legends for single-letter keys (e.g. `q` → `Q`, `ö` → `Ö`) + - Builds dead key compositions automatically using Unicode NFC normalization (e.g. `^` + `a` → `â`) +3. The frontend receives the processed layout via JSON-RPC and uses it for rendering, paste, and macro display + +#### Adding a new built-in layout + +1. Create a KLE JSON file at `internal/keyboard/layouts/.kle.json` (e.g. `ko_KR.kle.json`) + - IDs use hyphens (`ko-KR`) to match the format stored in device configs + - Get a baseline layout `.KLE.json` file: + - Copy any existing layout file + - Use [keyboard-layout-editor.com](https://www.keyboard-layout-editor.com) to design the layout, then export the JSON. + - Download the KLE json for any keyboard at [https://kbdlayout.info](https://kbdlayout.info) + - Key legends use `\n` to separate layers: `"shift\nnormal\naltgr\nshift+altgr"` + - Use Unicode symbols for special keys: `⌫` (Backspace), `↵` (Enter), `⇥` (Tab), `⇪` (Caps Lock), `↑↓←→` (arrows) +2. Run the tests to validate: `go test ./internal/keyboard/...` + - `TestAllBuiltinLayoutsParse` verifies every registered layout loads and parses + - `TestAllLayoutFilesRegistered` verifies every file in `layouts/` is registered + +#### User-uploaded layouts + +Users can also upload custom KLE JSON files via the settings UI or the HTTP endpoint (`POST /keyboard/upload`). These are stored on the device at `/userdata/kvm_layouts/` and appear alongside built-in layouts in the settings dropdown. + +For more details on the KLE format and transport schema, see [docs/keyboard/DESIGN.md](docs/keyboard/DESIGN.md) and [docs/keyboard/TRANSPORT.md](docs/keyboard/TRANSPORT.md). + --- **Happy coding!** diff --git a/Makefile b/Makefile index d824b95c0..4ab30921b 100644 --- a/Makefile +++ b/Makefile @@ -102,7 +102,7 @@ test_e2e: cd ui && npx playwright install chromium cd ui && $(call OTA_ENV,$(TEST_VERSION)) \ $(if $(JETKVM_REMOTE_HOST),JETKVM_REMOTE_HOST=$(JETKVM_REMOTE_HOST)) \ - npx playwright test --project=ui --project=remote-agent --project=ota-prerelease-unsigned --project=ota-upgrade-from-stable --project=ota-upgrade-to-signed + npx playwright test --project=ui --project=remote-agent --project=keyboard-paste --project=keyboard-macros --project=ota-prerelease-unsigned --project=ota-upgrade-from-stable --project=ota-upgrade-to-signed # Production release validation lane test_production_release: @@ -281,7 +281,7 @@ dev_release: git_check_dev check_r2 cd ui && npx playwright install --with-deps chromium cd ui && $(call OTA_ENV,$(VERSION_DEV)) \ JETKVM_REMOTE_HOST=$(JETKVM_REMOTE_HOST) \ - npx playwright test --project=ui --project=remote-agent --project=ota-prerelease-unsigned --project=ota-prerelease-rejected --project=ota-specific-version --project=ota-upgrade-from-stable --project=ota-upgrade-to-signed + npx playwright test --project=ui --project=remote-agent --project=keyboard-paste --project=keyboard-macros --project=ota-prerelease-unsigned --project=ota-prerelease-rejected --project=ota-specific-version --project=ota-upgrade-from-stable --project=ota-upgrade-to-signed @echo "───────────────────────────────────────────────────────" @echo " All tests completed. Everything is tested and ready for release." diff --git a/docs/keyboard/ADDING_A_LAYOUT.md b/docs/keyboard/ADDING_A_LAYOUT.md new file mode 100644 index 000000000..c97dcedec --- /dev/null +++ b/docs/keyboard/ADDING_A_LAYOUT.md @@ -0,0 +1,430 @@ +# Adding a Keyboard Layout to JetKVM — A Contributor's Walkthrough + +> **You are:** Someone who knows their physical keyboard layout and wants to add support for it (or fix one that's wrong). You don't need to know Go, you don't need to know JetKVM internals, you just need to be patient and read carefully. +> +> **You will:** Add a single JSON file to `internal/keyboard/layouts/`, run the test suite, click a button in the UI, and open a pull request. +> +> **Time:** 30–60 minutes the first time, much less for subsequent layouts. + +--- + +## Table of contents + +- [What you're building](#what-youre-building) +- [The two paths](#the-two-paths) + - [Path A — Clone an existing layout (recommended for QWERTY-family)](#path-a--clone-an-existing-layout-recommended-for-qwerty-family) + - [Path B — Start from kbdlayout.info (recommended for unfamiliar layouts)](#path-b--start-from-kbdlayoutinfo-recommended-for-unfamiliar-layouts) +- [Editing the metadata block](#editing-the-metadata-block) +- [Editing legends — how a string becomes a keycap](#editing-legends--how-a-string-becomes-a-keycap) +- [Naming and registration](#naming-and-registration) +- [Aliases (optional)](#aliases-optional) +- [Trying it out](#trying-it-out) +- [Submitting](#submitting) +- [Troubleshooting](#troubleshooting) +- [Reference: the metadata block](#reference-the-metadata-block) + +--- + +## What you're building + +JetKVM lets you control a remote machine. When you press a key in your browser, JetKVM sends a USB **scancode** (a numeric ID for a *physical* key) to the remote machine. The remote machine then interprets that scancode according to **its own** keyboard layout. + +This means: **the layout file isn't telling the remote machine which letters to type.** It's telling JetKVM: + +1. What the keys *look like* on the on-screen virtual keyboard. +2. Which Unicode characters can be pasted (the **paste text** feature) — and what scancode + modifier combination produces each character on a machine running this layout. + +```mermaid +flowchart LR + A[Your KLE JSON file] -->|parsed at startup| B[Go backend] + B -->|JSON-RPC over WebRTC| C[Browser virtual keyboard] + B -->|charMap lookup| D[Paste text feature] + C -->|click a keycap| E[USB HID scancode] + D -->|each char in pasted text| E + E -->|over USB| F[Remote machine] + F -->|uses its own layout| G[Typed character] +``` + +The whole pipeline is data-driven from your one JSON file. There is no Go code to write. + +--- + +## The two paths + +```mermaid +flowchart TD + Start{Is your layout
a Western/QWERTY
variant?} -->|Yes| A[Path A: Clone existing] + Start -->|No, or unsure| B[Path B: kbdlayout.info] + A --> Edit[Edit metadata + legends] + B --> Edit + Edit --> Save[Save to layouts/] + Save --> Test[Run the audit + tests] + Test --> UI[Try it in the UI] + UI --> PR[Open a PR] +``` + +Pick the path that fits. If you already know how `de_DE` or `fr_FR` works on a real keyboard, Path A is faster. If you have no idea what's where on, say, a Hungarian or Greek keyboard, Path B is much safer because you're copying from an authoritative source. + +--- + +## Path A — Clone an existing layout (recommended for QWERTY-family) + +1. Look at `internal/keyboard/layouts/`. Pick the file *closest* to your target. For a "QWERTY with one or two keys swapped" variant, `en_US.kle.json` is the natural starting point. For a typical European ISO layout, `de_DE.kle.json` (105-key ISO with dead keys) is a great template. + +2. Copy the file: + + ```bash + cp internal/keyboard/layouts/de_DE.kle.json internal/keyboard/layouts/xx_YY.kle.json + ``` + + Where `xx_YY` is your locale. **Use underscores in the filename** (more on this in [Naming and registration](#naming-and-registration)). + +3. Open the file in your editor. The structure is: + + ```jsonc + [ + { "name": "...", "author": "JetKVM", "deadKeys": [...] }, // metadata + [ /* row 1: function keys */ ], + [ /* row 2: number row */ ], + [ /* row 3: QWERTY row */ ], + [ /* row 4: ASDF row */ ], + [ /* row 5: ZXCV row */ ], + [ /* row 6: bottom row with Space */ ] + ] + ``` + +4. Skip ahead to [Editing the metadata block](#editing-the-metadata-block) and [Editing legends](#editing-legends--how-a-string-becomes-a-keycap). + +--- + +## Path B — Start from kbdlayout.info (recommended for unfamiliar layouts) + +[kbdlayout.info](https://kbdlayout.info/) catalogues every Windows keyboard layout. Each layout page gives you an exportable KLE JSON. + +1. Find your layout. The URL pattern is `https://kbdlayout.info//` where `` is an 8-character hex code Microsoft assigns. Browse [the index](https://kbdlayout.info/) or search. + +2. Click **Download** (top of the page) and pick **KLE-compatible JSON**. You get a file named something like `kbdhe.kle.json`. + +3. Move and rename it: + + ```bash + mv ~/Downloads/kbdhe.kle.json internal/keyboard/layouts/el_GR.kle.json + ``` + + The locale code (`el_GR` for Greek-Greece in this example) is what matters; the original filename is forgettable. + +4. Open the file. kbdlayout.info exports include a metadata block but **not** in the shape JetKVM wants. You'll fix that next. + +> **Tip:** Save the kbdlayout.info URL — you'll paste it into the metadata block as `kbdLayoutInfo` so future maintainers can re-check your layout against the source. + +--- + +## Editing the metadata block + +The **first element** of the top-level array is a property object holding the metadata. Edit it to look like this: + +```jsonc +[ + { + "name": "Ελληνικά el-GR (ISO 105)", + "author": "JetKVM", + "deadKeys": ["´", "¨", "`"], + "kbdLayoutInfo": "https://kbdlayout.info/00000408/" + }, + /* …rows of keys… */ +] +``` + +| Field | What goes in it | +|---|---| +| `name` | What appears in the JetKVM settings dropdown. Convention: **native name + locale code + form factor**, e.g. `"Deutsch de-DE (ISO 105)"`, `"日本語 ja-JP (JIS 109)"`. Use the layout's native script if there is one. | +| `author` | `"JetKVM"` for built-in layouts. If you want personal credit, your name is fine too. | +| `deadKeys` | The legend characters that act as **dead keys** (no character produced until the next key — typically accents like `^`, `´`, `¨`, `~`, `` ` ``). See [the dead keys section](#dead-keys-getting-this-right-matters). | +| `kbdLayoutInfo` | URL to the kbdlayout.info page for this layout. Optional but encouraged — it lets future contributors re-audit against the canonical source. | + +### Dead keys: getting this right matters + +A **dead key** is one that doesn't type a character on its own. Pressing it puts the keyboard in a "waiting" state; the *next* key you press combines with the dead key to produce an accented character. + +- French keyboard: pressing `^` then `a` produces `â`. The `^` key is a dead key. +- US keyboard: pressing `^` produces `^` immediately. The `^` key is **not** a dead key. + +If you list a character in `deadKeys`, JetKVM: + +- Renders that key with a small orange dot indicator. +- Generates composed characters in the paste-text dictionary (`â`, `é`, `ñ`, etc.) by combining the dead key with each base letter. +- Sends the dead key + Space when you paste a bare `^` (because that's how a real keyboard produces `^`). + +**Get this list from the operating system, not from the keycap legends.** Some keys *look* like accents but type immediately on a given layout. The simplest check: + +1. On a real machine running the target layout, press the suspected key. +2. If a character appears immediately → **not a dead key**, omit from the list. +3. If nothing appears until you press another key → **dead key**, include it. + +If your layout has no dead keys at all (`en-US`, `ru-RU`, etc.), **omit the `deadKeys` field entirely.** Don't write `"deadKeys": []` — omitting it is the canonical "no dead keys" signal. + +--- + +## Editing legends — how a string becomes a keycap + +Each row of the keyboard is a JSON array. Each element of that row is either a **property object** (size, gap, color) or a **legend string**. Property objects modify the *next* key; legend strings *are* keys. + +```jsonc +[ + { "w": 1.5 }, // next key is 1.5 units wide + "Tab", // ← a key + "Q", // ← a key + "W" // ← a key +] +``` + +### Layered legends — the `\n` separator + +A legend string can hold up to four characters, one per modifier layer, separated by `\n`: + +```text +"!\n1\n²\n¹" + │ │ │ └─ Shift+AltGr + │ │ └──── AltGr + │ └─────── Normal (unmodified press) + └────────── Shift +``` + +Any layer can be empty. To skip a slot, leave it blank between separators: `"!\n1"` (only Normal and Shift), `"\n\n²"` (only AltGr). + +**Single-legend keys** — keys with only one label (Space, Tab, Enter, F1…) — are written as just `"Space"`, `"Tab"`, etc. The parser treats a single legend as the unmodified press automatically. + +### How layers map to corners on the rendered keycap + +In the on-screen virtual keyboard, the four layer slots show up in four corners of the keycap: + +``` + ┌───────────────────┐ + │ Shift ShiftAltGr│ + │ │ + │ │ + │ Normal AltGr │ + └───────────────────┘ +``` + +A real example — the Q key on `de_DE`: + +| Layer | Value | Where it shows | Why | +|---|---|---|---| +| Normal | `q` | bottom-left | unmodified | +| Shift | `Q` | top-left | Shift held | +| AltGr | `@` | bottom-right | AltGr held | +| ShiftAltGr | (empty) | not shown | nothing assigned | + +Encoded: `"Q\nq\n\n@"` — Shift, Normal, ShiftAltGr (empty), AltGr. + +> **Heads up — letter keys auto-case for you.** If you write just `"q"`, JetKVM auto-fills Shift with `"Q"`. If you write just `"Q"`, JetKVM auto-fills Normal with `"q"` and Shift with `"Q"`. So `"q"`, `"Q"`, `"q\nQ"`, and `"Q\nq"` all render identically. Don't worry about it. + +### Single-character keycaps render centred + +Any key whose only legend is a single label (`"Space"`, `"F1"`, `"Caps Lock"`, the spacebar) renders with the legend centred — the four-corner layout only kicks in when there are *multiple* legends. + +``` + ┌────────────────┐ + │ │ + │ Space │ ← single legend, centred + │ │ + └────────────────┘ +``` + +### Special characters in legends + +Some characters need to be JSON-escaped: + +| Character | In a JSON string | Notes | +|---|---|---| +| `"` (double quote) | `\"` | because `"` ends the JSON string | +| `\` (backslash) | `\\` | so backslash-backslash, two characters | +| Newline (layer separator) | `\n` | the layer separator itself | +| Anything Unicode | Just paste it | UTF-8 is fine | + +Example — the QWERTY backslash key on `en_US` is `"|\n\\"` (Shift = `|`, Normal = backslash). The `\\` is one backslash character escaped for JSON. + +### Width, gaps, special shapes + +These come from the property objects between legend strings: + +| Property | Meaning | Common values | +|---|---|---| +| `"w": N` | next key is `N` units wide | `1` (default), `1.25`, `1.5`, `1.75`, `2`, `2.25`, `2.75`, `6.25` (spacebar) | +| `"x": N` | shift the next key right by `N` units (a gap) | `0.25`, `0.5`, `1` | +| `"y": N` | shift the row down by `N` units (vertical gap) | `0.5` (between F-row and number row) | +| `"h": N` | next key is `N` units tall | `2` (numpad Enter, ISO Enter "tall" part) | +| `"w2", "h2", "x2", "y2"` | second rectangle for L-shaped keys (ISO Enter) | see existing `de_DE`/`en_UK` for examples | +| `"d": true` | this is a **decal** (label printed on the case, not a physical key) | rare | +| `"n": true` | homing bump (typically F and J) | optional | +| `"a": N` | label alignment | mostly leave alone; see [KLE wiki](https://github.com/ijprest/keyboard-layout-editor/wiki/Serialized-Data-Format) | + +If you started from kbdlayout.info or cloned an existing layout, all of this is already correct — you'll mostly only edit the legend strings themselves. + +### Don't worry about scancodes + +You almost never set scancodes manually. JetKVM **infers** the scancode for each key from its position on the board (a Q on a 105-key ISO board is always HID `0x14`, no matter the layout). If your layout uses a non-standard physical arrangement, see the [Scancode overrides section in DEVELOPMENT.md](DEVELOPMENT.md#scancode-overrides-for-non-standard-layouts), but most contributors will never touch this. + +### A note for kbdlayout.info exports — `"X\nX"` shorthand + +kbdlayout.info likes to write numpad keys and unmodifiable keys as `"7\n7"`, `"+\n+"`, `"Space\nSpace"` — the same legend on Shift and Normal. JetKVM **automatically collapses** these to a single legend at parse time, so you can leave them as-is *or* simplify them by hand. Both forms produce identical results. + +--- + +## Naming and registration + +### File naming + +Save your file as: + +``` +internal/keyboard/layouts/.kle.json +``` + +Use **underscores** in the filename: `de_DE`, `fr_BE`, `ja_JP`, `el_GR`. The convention is `language_REGION` with the language part lowercased and the region uppercased. + +### Layout ID + +The layout ID is the same string with a hyphen instead of an underscore: `de-DE`, `fr-BE`, `ja-JP`, `el-GR`. This is the value that ends up in the device's saved configuration and the dropdown identifier. + +You don't write the ID anywhere — it's derived from the filename automatically. + +### Registration + +There is none. Drop the file in `internal/keyboard/layouts/`, rebuild the binary, and the layout appears in the UI. The build system uses Go's `go:embed` to bundle every `*.kle.json` file in the directory. + +```mermaid +flowchart LR + A[layouts/xx_YY.kle.json] -->|go:embed| B[Compiled into binary] + B -->|/keyboard/layouts RPC| C[Settings dropdown] + C -->|user selects xx-YY| D[Active layout] +``` + +--- + +## Aliases (optional) + +Some locales have multiple commonly used codes. For example, Belgium uses both `nl-BE` (Dutch) and `fr-BE` (French) for what is functionally the same physical layout. Rather than ship two copies, JetKVM supports **aliases**. + +To add an alias, edit `internal/keyboard/builtin.go` and add an entry to `layoutAliases`: + +```go +var layoutAliases = map[string]string{ + "nl-BE": "fr_BE", // Belgian AZERTY — same physical layout + "your-alias-here": "filename_stem", +} +``` + +The alias key is the public ID; the value is the file stem (without `.kle.json`). Use this when two locale codes deserve the same physical layout. Don't use it as a substitute for actually shipping a separate layout when keys differ. + +--- + +## Trying it out + +### 1. Validate the layout statically + +```bash +go test ./internal/keyboard/... +``` + +The test suite parses every layout, checks key counts, ensures every legend is recognised, and exercises the dead-key composition machinery. If your file fails parsing or has unknown legends, you'll see a clear error here. + +### 2. Compare against kbdlayout.info + +```bash +go run ./scripts/audit_layouts.go xx_YY +``` + +If you set `kbdLayoutInfo` in the metadata, this downloads the reference KLE from kbdlayout.info and compares it against your local file character-by-character. Output: + +- `PASS` — the layout matches the reference closely enough. +- `WARN` — there are differences. Run with `-v` for detail: `go run ./scripts/audit_layouts.go -v xx_YY`. The script labels expected differences (ISO/ANSI key remaps, dead-key indirection) as `[allow]`. Anything labelled `[warn]` or `[FAIL]` deserves your attention. + +### 3. Try it in the actual UI + +Build and deploy the binary to your dev device: + +```bash +./dev_deploy.sh -r +``` + +Open the JetKVM web UI, go to **Settings → Keyboard**, and pick your new layout from the dropdown. You should see: + +- The on-screen virtual keyboard renders with your legends. +- Keys you marked as dead keys show a small orange dot. +- Holding Shift on the virtual keyboard or your physical keyboard flips legends to the Shift layer. +- Holding AltGr (Right Alt) flips legends to the AltGr layer. + +### 4. Test paste + +Connect the JetKVM to a target machine that's set to your layout. Open a text editor on that machine. In JetKVM, click **Paste Text**, type in some characters that include accents, dead-key compositions, and AltGr-layer characters, and confirm. + +The target should receive exactly the text you pasted. If it gets garbled or a character disappears, your `deadKeys` list or one of the legends is probably off. + +```mermaid +flowchart LR + A[You paste 'café' in JetKVM] -->|charMap lookup| B[c → key C, no mods] + A --> C[a → key A, no mods] + A --> D[f → key F, no mods] + A --> E[é → ´ + e] + B & C & D & E --> F[USB HID events] + F --> G[Target machine types 'café'] +``` + +--- + +## Submitting + +1. Run `go test ./internal/keyboard/...` once more. +2. Commit your single new file (and the `builtin.go` edit if you added an alias). +3. Open a pull request against `dev`. The PR description should cover: + - Which layout you're adding (locale code + native name). + - Which kbdlayout.info page or other source you used. + - Whether you have access to a real machine running this layout to validate paste round-trips. +4. The maintainers will run the audit script and may ask for tweaks if the comparison flags something unexpected. + +--- + +## Troubleshooting + +| Symptom | What it usually means | +|---|---| +| `go test` fails with *"layout only has N keys — does not look like a full keyboard"* | A row is malformed, or you accidentally deleted too many keys. Open the file in [keyboard-layout-editor.com](https://www.keyboard-layout-editor.com) (Raw Data tab) to visualise it. | +| `go test` fails with *"only N% of keys mapped to HID scancodes"* | Your layout's physical arrangement doesn't match a standard 100/105/109-key board. Either re-arrange the rows to match a standard board, or use the `scancodes` metadata override (see DEVELOPMENT.md). | +| Audit reports *"charMap missing 'X'"* | A character that the reference layout produces is unreachable in your charMap. Usually a missing legend on the right key. | +| Audit reports *"legend differs"* on a specific scancode | One of your legend strings differs from the reference. Verify the legend on the relevant key. | +| The keycap shows the legend in the wrong corner | You put the character in the wrong layer slot. See [How layers map to corners](#how-layers-map-to-corners-on-the-rendered-keycap). | +| Pasted text comes out wrong on the target | Either your `deadKeys` list is wrong, or a layered character is in the wrong slot. Run the audit with `-v` to compare against the reference. | + +--- + +## Reference: the metadata block + +Full schema of the optional metadata object (first element of the top-level array): + +```jsonc +{ + // Standard KLE fields (rendered by keyboard-layout-editor.com but not by JetKVM): + "name": "Display name", + "author": "Attribution", + "notes": "Free-form notes", + "backcolor": "#background", + "radii": "key corner radii spec", + + // JetKVM extensions: + "deadKeys": ["^", "´"], // optional; omit if no dead keys + "scancodes": { "42": 76 }, // optional; per-key HID overrides + "kbdLayoutInfo": "https://kbdlayout.info//" // optional; for the audit script +} +``` + +See [TRANSPORT.md](TRANSPORT.md) for the rest of the wire format if you're curious about what JetKVM does with the parsed result. + +--- + +## Final note + +Most layouts in `internal/keyboard/layouts/` were added by contributors who did exactly the steps above. Read a few of them before you start — `de_DE.kle.json` is small and well-shaped, `ja_JP.kle.json` shows the kana layers, `cs_CZ.kle.json` has the busiest dead-key set. Pattern-matching against existing files is the fastest way to a good PR. + +Welcome aboard. diff --git a/docs/keyboard/DESIGN.md b/docs/keyboard/DESIGN.md new file mode 100644 index 000000000..0940fcc2d --- /dev/null +++ b/docs/keyboard/DESIGN.md @@ -0,0 +1,710 @@ +# JetKVM Virtual Keyboard — Design Document + +> **Purpose:** Design and implementation record for the KLE-based virtual keyboard system in the JetKVM React frontend. +> +> **Just want to add a layout?** This document is the wrong place. See **[ADDING_A_LAYOUT.md](ADDING_A_LAYOUT.md)** for the step-by-step contributor guide. + +--- + +## Table of Contents + +- [JetKVM Virtual Keyboard — Design Document](#jetkvm-virtual-keyboard--design-document) + - [Table of Contents](#table-of-contents) + - [Background](#background) + - [Problem Statement](#problem-statement) + - [Architecture Overview](#architecture-overview) + - [Physical Keyboard: Why `event.code` Is Correct](#physical-keyboard-why-eventcode-is-correct) + - [How it works](#how-it-works) + - [Why `event.key` was considered and rejected](#why-eventkey-was-considered-and-rejected) + - [When layouts mismatch](#when-layouts-mismatch) + - [KLE Format Primer](#kle-format-primer) + - [Top-Level Structure](#top-level-structure) + - [Key Legend Encoding](#key-legend-encoding) + - [Key Property Objects](#key-property-objects) + - [What KLE Does NOT Contain (and JetKVM Extensions)](#what-kle-does-not-contain-and-jetkvm-extensions) + - [Data Flow](#data-flow) + - [HID Scancode Inference](#hid-scancode-inference) + - [Dead Key Compositions](#dead-key-compositions) + - [Scancode Overrides via KLE Metadata](#scancode-overrides-via-kle-metadata) + - [Compact Form Factor Support](#compact-form-factor-support) + - [Auto-Case Legends](#auto-case-legends) + - [Component Tree](#component-tree) + - [CSS-First Rendering Strategy](#css-first-rendering-strategy) + - [Layer Switching](#layer-switching) + - [All-Layers Preview Mode](#all-layers-preview-mode) + - [Key Sizing](#key-sizing) + - [LED Indicators](#led-indicators) + - [Built-in Layouts](#built-in-layouts) + - [File Reference](#file-reference) + - [Design note: why Go parses, not the client](#design-note-why-go-parses-not-the-client) + - [References](#references) + - [Open Issues Addressed](#open-issues-addressed) + - [UI Languages Without a Dedicated Keyboard Layout](#ui-languages-without-a-dedicated-keyboard-layout) + - [What Is Not In Scope](#what-is-not-in-scope) + +--- + +## Background + +JetKVM is a KVM-over-IP device: it sits between a controller (the person) +and a target machine (the server/PC being managed), forwarding keyboard, +video, and mouse over WebRTC. The frontend is a React + TypeScript app +served from the device itself. + +The keyboard subsystem covers three intertwined concerns: + +- **Virtual on-screen keyboard** — must render the *target's* layout + (correct shapes, legends in all layers) for any locale users select. +- **Paste Text** — must convert pasted Unicode characters into the + HID scancode + modifier combinations the target's layout produces. +- **Physical-keyboard passthrough** — must use position-based scancodes + (HID Usage IDs) so the operator's keyboard behaves like a USB cable + into the target, regardless of the operator's own layout. + +The system is driven by KLE JSON files (one per layout) parsed in Go and +served to the React client. Built-in layouts live under +`internal/keyboard/layouts/`; users can also upload their own. Tracked +in GitHub issues #1184, #1067, #65, #30, #649, #223. + +--- + +## Problem Statement + +The key concept is the **target layout** — the keyboard layout configured on +the *controlled machine*. The physical keyboard passthrough uses HID scancodes +(position-based, like a USB cable), which is correct KVM behaviour. But the +virtual keyboard, paste text, and macro display all need to know the target +layout to show correct legends and send correct character sequences. + +The virtual on-screen keyboard needs to: + +- Accurately represent the **target** layout (correct key shapes, legends in all layers) +- Be driven by a standard, community-familiar format +- Switch between Shift/AltGr layers with **zero JavaScript rerender** (pure CSS) + +--- + +## Architecture Overview + +```mermaid +graph TD + subgraph "Browser: Settings Page" + UP[KLE JSON Upload\nuseKleUpload hook] -->|POST /keyboard/upload\nraw KLE JSON body| GOPARSE + RAWPASTE[Paste Raw KLE JSON] -->|POST /keyboard/upload| GOPARSE + end + + subgraph "Go Backend: internal/keyboard" + GOPARSE[ParseKLE\nkeyboard.go] --> VALIDATE[Validate] + VALIDATE --> STORE[Store\n/userdata/kvm_layouts/id.layout.json] + GOPARSE --> SCANCODE[inferScancodeWithTable\nscancode.go] + GOPARSE --> CHARMAP[buildCharMap +\naddDeadKeyCompositions] + BUILTINS[Built-in layouts\ngo:embed layouts/*.kle.json] --> RPC + STORE --> RPC[getKeyboardLayoutData\nhandler.go] + end + + subgraph "Browser: KVM Session View" + RPC -->|KeyboardLayout JSON\nkeyboard/types/schema.ts| VKB[VirtualKeyboard Component] + META[useKeyboard hook\nMETA state] --> VKB + VKB --> KEYCAP[Keycap × N\ntransportKey prop] + KEYCAP --> CSS[data-layer CSS\nattribute switch] + RPC -->|charMap\nwith dead key prefixes| PASTE[PasteModal\nexecuteHidMacro] + RPC -->|charMap| MACROS[Macro UI\nbuildKeyDisplayMap] + RPC -->|charMap| TYPEMACRO[textToMacroSteps\nconverts text → macro steps\nvia charMap] + RPC -->|KeyboardLayout| PREVIEW[LayoutPreviewDialog\ninteractive key flash] + end + + subgraph "HID Layer" + KEYCAP -->|onPointerDown\nscancode| HIDRPC[hidrpc / jsonrpc] + PASTE --> HIDRPC + HIDRPC --> USB[USB HID Gadget\nusb.go] + end +``` + +--- + +## Physical Keyboard: Why `event.code` Is Correct + +The physical keyboard handler in `WebRTCVideo.tsx` uses `event.code` (physical +key position) to derive HID scancodes. This is the **correct behaviour** for a +KVM device — it matches how a physical USB cable or hardware KVM switch works. + +### How it works + +1. Operator presses a key → browser reports `event.code` (physical position) +2. We map `event.code` → HID Usage ID (the same scancode the physical key + would produce over USB) +3. Target OS receives the scancode and interprets it based on **its own** + configured keyboard layout + +This is transparent physical position passthrough — exactly what hardware does. + +### Why `event.key` was considered and rejected + +A proposal for using `event.key` (the logical character the operator's OS resolved) +to look up the target scancode via `charMap`. This would "translate" between +mismatched operator/target layouts. + +This was rejected because: + +| Concern | Detail | +|---|---| +| **Dead keys** | `event.key` is `"Dead"` for dead key presses — we lose which dead key was pressed | +| **IME input** | `event.key` is `"Process"` during CJK composition — breaks Japanese/Chinese/Korean input | +| **Modifier combos** | `event.key` for Ctrl+C can be `"\x03"` or `"c"` depending on browser | +| **Existing workarounds** | The AltGr Windows fix, Meta key workaround, and IME fix in `WebRTCVideo.tsx` all rely on `event.code` semantics | +| **KVM expectations** | Power users expect a KVM to behave like a USB cable. Character translation would be surprising and hard to debug | + +### When layouts mismatch + +If the operator has a French AZERTY keyboard but the target is configured for +German QWERTZ, physical position passthrough means the operator must "think in +the target layout" — the same experience as plugging a French keyboard directly +into the German machine via USB. This is a fundamental property of KVM devices. + +The virtual keyboard and paste text system **do** use the target layout's +charMap for character-accurate input — these are the correct tools for when +the operator needs to type characters that don't exist on their physical +keyboard or when layouts differ. + +--- + +## KLE Format Primer + +[keyboard-layout-editor.com](https://www.keyboard-layout-editor.com) exports a JSON format that is the de-facto standard for describing physical keyboard layouts. It is widely used in the custom keyboard community, meaning thousands of layouts already exist. + +### Top-Level Structure + +```json +[ + { "name": "German QWERTZ", "author": "example" }, + ["^", "!\n1\n¹\n²", "\"\n2\n³", "§\n3\n³", ...], + [{"w":1.5}, "Tab", "Q", "W", ...] +] +``` + +- First element (optional): metadata object +- Remaining elements: arrays representing keyboard rows +- Each row contains strings (key legends) and objects (property modifiers for subsequent keys) + +### Key Legend Encoding + +Legends are newline-separated strings. Following the standard KLE community +convention (shift-first), the position indices map to keyboard layers as: + +``` +position 0 = shifted (top-left on keycap) +position 1 = unshifted (bottom-left on keycap) +position 2 = Shift+AltGr (top-right on keycap) +position 3 = AltGr (bottom-right on keycap) +``` + +So `"!\n1\n¹\n²"` means: +- Shift: `!` +- Normal: `1` +- Shift+AltGr: `¹` +- AltGr: `²` + +Both JetKVM's built-in layouts and community KLE files from +keyboard-layout-editor.com use this convention, so uploaded layouts +parse correctly without any configuration. + +### Key Property Objects + +Property objects appear before the keys they modify: + +```json +{"w": 1.5} // next key is 1.5 units wide +{"x": 0.5} // add 0.5u gap before next key +{"w": 1.25, "h": 2, "w2": 1.5, "h2": 1, "x2": -0.25} // ISO Enter +{"c": "#ff0000"} // all subsequent keys are red (KLE colorway) +``` + +Properties `w`, `h`, `x`, `y`, `w2`, `h2`, `x2`, `y2`, `l` (stepped), `n` (homing), `d` (decal) apply to the **next key only**. + +Properties `c` (color), `t` (text color), `a` (alignment), `f` (font size) apply to **all subsequent keys**. Note: the `a` (alignment) property is parsed but not consumed — the parser always uses the standard KLE legend slot mapping regardless of alignment value. + +### What KLE Does NOT Contain (and JetKVM Extensions) + +Standard KLE has no concept of: + +- **HID scancodes** — inferred from physical position by `inferScancodeWithTable()` in `scancode.go`. Can be overridden per-key via the `scancodes` metadata extension. +- **Dead key declarations** — the `deadKeys` metadata extension declares which legend characters are dead keys. This gates both the CSS dead key indicator AND charMap composition generation. Dead key composition rules are derived from Unicode NFC normalization by `addDeadKeyCompositions()` in `keyboard.go`. Layouts without `deadKeys` produce no compositions. + +JetKVM extends the KLE metadata object with two optional fields: + +| Field | Type | Purpose | +|---|---|---| +| `deadKeys` | `string[]` | Legend characters that are dead keys (drives CSS `.dead` class AND charMap composition generation) | +| `scancodes` | `Record` | Key index to HID Usage ID overrides | + +--- + +## Data Flow + +```mermaid +flowchart LR + subgraph "Input: KLE JSON" + KLEFILE[user-uploaded\nor built-in .kle.json] + end + + subgraph "parseKLE()" + KLEFILE --> META2[Extract metadata] + KLEFILE --> ROWS[Parse rows\naccumulate x/y] + ROWS --> PROPS[Apply property\nobjects] + PROPS --> LEGENDS[Split legend string\nshift-first → normal/shift\nauto-case letters] + LEGENDS --> SHAPE[Detect shape\niso-enter / stepped] + SHAPE --> SCANCODE[Infer HID scancode\nfrom x/y position] + end + + subgraph "KeyboardLayout" + SCANCODE --> PKB[keys: TransportKey array\nboardW, boardH] + end + + subgraph "Derived maps" + PKB --> CHARMAP[buildCharMap:\nnormal → scancode+0\nshift → scancode+SHIFT\naltgr → scancode+ALTGR] + CHARMAP --> DEADKEYS[addDeadKeyCompositions:\nUnicode NFC normalization\ndead key + base → composed] + end + + subgraph "Usage" + PKB --> VKBRENDER[VirtualKeyboard\nrender] + DEADKEYS --> PASTETXT[PasteModal\nchar→HID lookup\nwith dead key prefixes] + DEADKEYS --> MACRODISPLAY[Macro UI\nbuildKeyDisplayMap] + end +``` + +### HID Scancode Inference + +Since KLE doesn't carry scancodes, we infer from physical position using +two position tables in `internal/keyboard/scancode.go`. + +**Full-size table** (ANSI 104, ISO 105, JIS 109 — `boardW > 20` or `keyCount >= 100`): + +Standard KLE templates have a y:0.5 gap between the function row and the +number row. `math.Round(y)` maps the fractional Y positions to integer row +indices: + +``` +Row 0 (y=0.00): Escape, F1-F12, PrtSc/ScrLk/Pause +Row 2 (y=1.50): `, 1-9, 0, -, =, Backspace, Insert/Home/PgUp, NumLock/÷/×/− +Row 3 (y=2.50): Tab, Q-P, [, ], \, Delete/End/PgDn, KP7-9/+ +Row 4 (y=3.50): CapsLock, A-L, ;, ', Enter, KP4-6 +Row 5 (y=4.50): LShift, Z-M, ,, ., /, RShift, ↑, KP1-3/Enter +Row 6 (y=5.50): LCtrl, Meta, LAlt, Space, RAlt..., ←↓→, KP0/. +``` + +Note: Row 1 does not exist (y:0.5 gap means y=1.5 rounds to 2, not 1). + +**Compact table** (75%, TKL — `boardW <= 20`, `keyCount < 100`, `boardH >= 6`): + +For keyboards without the y:0.5 gap, rows sit at integer Y positions: + +``` +Row 0 (y=0): Esc, F1-F12 (packed, no gaps between F-key groups) +Row 1 (y=1): `, 1-9, 0, -, =, Backspace +Row 2 (y=2): Tab, Q-P, [, ], \ +Row 3 (y=3): CapsLock, A-L, ;, ', Enter +Row 4 (y=4): LShift, Z-M, ,, ., /, RShift, ↑ +Row 5 (y=5): LCtrl, Meta, LAlt, Space, RAlt..., ←↓→ +``` + +**60%/65% keyboards** (fewer than 6 rows or no function row) fall back to the +full-size table. Since their row Y positions don't match either table well, +most keys will get `scancode=0`. These layouts require `scancodes` metadata +overrides to be usable. + +The lookup algorithm walks each row left-to-right, finding the last entry +where `xStart <= key.X + epsilon`. Special cases: +- **ISO Enter** (`h >= 2, x < 15`): always maps to `hidEnter` +- **ISO hash key** (`x ≈ 12.75, w < 1.5`): maps to `hidHashTilde` (narrow), while + ANSI Enter at the same x (wider, `w >= 2`) is caught by the table entry + +Both tables cover ANSI, ISO, and basic numpad/nav cluster positions. JIS-specific +keys (Yen, Ro, Muhenkan, Henkan, Kana) are handled by position as well. + +### Dead Key Compositions + +Dead keys don't produce a character immediately — they modify the next keypress. +The `deadKeys` metadata declaration in each KLE file gates **both** the visual +indicator and the charMap composition generation. + +**Supported dead key characters** (`deadKeyToCombining` in `keyboard.go`): + +| Legend | Unicode name | Combining form | Example | +|--------|-------------|----------------|---------| +| `^` | Circumflex | U+0302 | ^+a → â | +| `` ` ``| Grave | U+0300 | `+e → è | +| `´` | Acute | U+0301 | ´+e → é | +| `¨` | Diaeresis | U+0308 | ¨+u → ü | +| `~` | Tilde | U+0303 | ~+n → ñ | +| `˜` | Tilde (spacing variant) | U+0303 | same as ~ | +| `¸` | Cedilla | U+0327 | ¸+c → ç | +| `˛` | Ogonek | U+0328 | ˛+a → ą | +| `˙` | Dot above | U+0307 | ˙+z → ż | +| `˚` | Ring above (spacing) | U+030A | ˚+u → ů | +| `°` | Ring above (degree sign) | U+030A | same as ˚ (Czech keyboards) | +| `˝` | Double acute| U+030B | ˝+o → ő | +| `ˇ` | Caron (háček)| U+030C | ˇ+s → š | +| `˘` | Breve | U+0306 | ˘+a → ă | + +Note: `~`/`˜` and `˚`/`°` are alternate legend glyphs that map to the same +combining character. The lookup uses the actual legend from the key, so +standalone dead key entries always match the correct display character. + +**1. Dead key CSS flag (metadata-driven)** + +The `deadKeys` array in the KLE metadata declares which legend characters are +dead keys for this layout. Example from `de-DE`: + +```json +{ "name": "Deutsch de-DE (ISO 105)", "deadKeys": ["^", "´", "`"] } +``` + +For each key, every legend slot whose value matches a declared dead key +character is recorded by name (`"normal"`, `"shift"`, `"altgr"`, …) in +the `deadLegends` array on its `TransportKey`. The frontend applies the +`.dead` CSS class to the matching legend slot only — so a key whose AltGr +layer is a dead key but whose Normal layer isn't shows the dot only on +the AltGr corner. If the metadata has no `deadKeys` array (e.g. `en-US`), +no slot is ever flagged and no compositions are generated (see below). + +**2. Dead key compositions in charMap (metadata-gated, Unicode NFC)** + +`addDeadKeyCompositions()` generates composed character entries for the +paste/macro system, but **only for layouts that declare dead keys** in their +`deadKeys` metadata. This is critical: on a US keyboard `^` is just Shift+6 +and produces the character directly — sending it as a dead key prefix during +paste would produce `^a` instead of `â` on the target machine. + +The process for layouts that do declare dead keys: + +1. For each key legend, check if it appears in both the layout's `declaredDeadKeys` + set AND the `deadKeyToCombining` table. Only these are treated as dead keys. + The **display character** (the actual legend rune) is captured alongside the + combining form — this avoids a non-deterministic reverse map lookup for + characters like `~`/`˜` that share the same combining code point. +2. For each dead key × base character pair, `norm.NFC` checks for composed forms. + If NFC produces a different single character, it's a valid composition. +3. Composed characters get a `HIDCombo` with a `Prefix` field — e.g. + `"â"` → `{s: 4, m: 0, p: {s: 47, m: 0}}` (press `^` dead key, then `a`) +4. Standalone dead key characters (e.g. typing just `^`) get `Prefix` + Space + follow-up, using the captured display character for the charMap key. + +Layouts without `deadKeys` metadata (e.g. `en-US`, `ru-RU`) produce zero +prefixed charMap entries — characters like `^`, `~`, `` ` `` are treated as +normal keys that output directly. + +The frontend PasteModal checks `combo.p` and sends the prefix keystroke +first when present. + +### Scancode Overrides via KLE Metadata + +Position-based scancode inference works well for standard ANSI, ISO, and JIS +layouts, but non-standard form factors (split, ortholinear, custom) may have +keys in positions that don't match any table entry. + +The `scancodes` field in KLE metadata maps a **key index** (0-based, in parse +order) to a USB HID Usage ID, overriding whatever `inferScancodeWithTable()` would +have returned: + +```json +{ + "name": "My Custom Layout", + "scancodes": { "42": 76, "55": 83 } +} +``` + +In this example, key #42 is forced to scancode 76 (0x4C = Delete) and key #55 +to scancode 83 (0x53 = NumLock). Overrides are applied during the single-pass +scancode inference — keys with a metadata override skip position-based lookup +entirely. + +This is primarily useful for: + +- Community-uploaded layouts with unusual physical arrangements +- Keys that fall outside the standard ANSI/ISO grid +- 60%/65% layouts where position-based inference fails (no matching table) +- Edge cases in 75%/TKL layouts where the compact table maps a key incorrectly + +### Compact Form Factor Support + +The scancode inference engine supports **75% and TKL** compact keyboards +in addition to full-size layouts. + +`selectPositionTable()` in `scancode.go` selects the position table in a +**single pass** after all keys are parsed and board dimensions are known: + +- **Full-size** (`boardW > 20` or `keyCount >= 100`): `fullSizeTable` — + expects the y:0.5 gap, numpad, and nav cluster. +- **Compact** (`boardW <= 20`, `keyCount < 100`, `boardH >= 6`): `compactTable` — + rows at integer Y (no y:0.5 gap), no numpad. Requires a function row at y=0. +- **60%/65% fallback** (fewer than 6 rows): falls back to `fullSizeTable`. + Since row Y positions don't match either table, most keys get `scancode=0`. + These layouts need `scancodes` metadata overrides to work. + +The table is selected once and all scancodes are inferred in a single pass. +Keys that have a scancode override from metadata are skipped during inference. + +For edge cases where the compact table produces incorrect mappings, +the `scancodes` metadata field can provide per-key overrides (see above). + +### Auto-Case Legends + +The Go parser auto-generates case pairs for single-letter keys. In the +standard KLE shift-first convention, a single-letter legend like `"Q"` +lands in the shift slot (position 0) with no normal slot. The parser +detects this and produces `normal: "q", shift: "Q"` — the **mirror-case** +logic. Lowercase single letters (e.g. `"q"`) work the same way: the +parser fills in the missing shift legend. + +This works for Latin, accented (ö → Ö), and Cyrillic (й → Й) characters. +Multi-character legends like `"Tab"` or `"Enter"` are not affected. + +A round-trip safety check prevents incorrect auto-casing for scripts where +case mapping is not bijective — notably Turkish dotless-ı (U+0131) and +dotted-İ (U+0130), where `ToUpper('ı') = 'I'` but `ToLower('I') = 'i' ≠ 'ı'`. +Keys with these characters must specify both legends explicitly. + +--- + +## Component Tree + +```mermaid +graph TD + WRAP["<KeyboardWrapper>\n─────────────────\nstate: modifier latching\nderives pressedScancodes\nfrom keysDownState (HID store)\nheader: QuickActions + layout link"] + + VKB["<VirtualKeyboard>\n─────────────────\npure renderer (no state mgmt)\nderives layer from pressedScancodes\nprop: keyboard: KeyboardLayout\nprop: onKeySend: fn\nprop: pressedScancodes: Set"] + + BOARD["<div.vkb>\n─────────────────\ndata-layer={layer}\n--board-w / --board-h\nCSS positions all children"] + + KC["<Keycap> × N\n─────────────────\nmemo()\nno state\nonPointerDown → onKeySend\nrenders 1-4 legend spans\nlocalized aria labels"] + + LEG["<span.legend.{layer}>\n─────────────────\npure text node\nvisibility controlled\nentirely by CSS"] + + WRAP --> VKB + VKB --> BOARD + BOARD --> KC + KC --> LEG +``` + +**Key design rules:** + +- **Single source of truth:** `keysDownState` from the HID store drives all visual state. The wrapper decodes both `keys[]` (non-modifier scancodes) and `modifier` byte (via `hidKeyToModifierMask` reverse lookup) into a single `pressedScancodes` set. +- **Modifier latching:** Virtual keyboard modifier clicks toggle on/off (press once to hold, press again to release). Controlled by the `modifierLatching` user setting. Visual state comes from `keysDownState`. +- **Layer derivation:** `VirtualKeyboard` derives the display layer purely from `pressedScancodes`. Default is `'all'` (quadrant preview). When Shift/AltGr scancodes are present, switches to single-layer view. +- **Pure renderer:** `VirtualKeyboard` has no effects, no refs, no event listeners — it's a pure function of its props. +- `QuickActions` lives in the `KeyboardWrapper` header, not inside `VirtualKeyboard` +- `Keycap` is `memo()`'d — layer changes do NOT trigger keycap rerenders +- All legend show/hide logic is **CSS only** via `data-layer` attribute +- `onPointerDown` (not `onClick`) prevents focus steal from video feed +- Aria labels map key symbols (both symbol-only `⇧` and compound `⇧ Shift`) to localized names via `KEY_ARIA_NAMES` in `Keycap.tsx`, derived at module load from the shared taxonomy in `internal/keyboard/keyaliases.json` (the same JSON the Go parser uses to canonicalise legends — single source of truth for both sides) + +--- + +## CSS-First Rendering Strategy + +The entire layer-switching mechanism is a single attribute change on `.vkb`. No JavaScript computes which legend is visible. + +### Layer Switching + +```css +/* Hide all legends by default */ +.vkb .legend { display: none; } + +/* Show only the active layer */ +.vkb[data-layer="normal"] .legend.normal { display: flex; inset: 0; align-items: center; justify-content: center; } +.vkb[data-layer="shift"] .legend.shift { display: flex; inset: 0; align-items: center; justify-content: center; } +.vkb[data-layer="altgr"] .legend.altgr { display: flex; inset: 0; align-items: center; justify-content: center; } +.vkb[data-layer="shift-altgr"] .legend.shift-altgr { display: flex; inset: 0; align-items: center; justify-content: center; } + +/* Fallback: if no legend for this layer, show normal at 50% opacity */ +.vkb[data-layer="shift"] .key:not(:has(.legend.shift)) .legend.normal { display: flex; opacity: 0.5; } +.vkb[data-layer="altgr"] .key:not(:has(.legend.altgr)) .legend.normal { display: flex; opacity: 0.5; } +``` + +### All-Layers Preview Mode + +```css +/* Quadrant layout when data-layer="all" */ +.vkb[data-layer="all"] .legend { display: flex; font-size: 0.6rem; } +.vkb[data-layer="all"] .legend.normal { bottom: 3px; left: 4px; } +.vkb[data-layer="all"] .legend.shift { top: 3px; left: 4px; } +.vkb[data-layer="all"] .legend.altgr { bottom: 3px; right: 4px; } +.vkb[data-layer="all"] .legend.shift-altgr{ top: 3px; right: 4px; } +``` + +### Key Sizing + +The keyboard auto-scales to fit its container using CSS container queries. +The unit size `--u` is derived from the container width and the board's +total width in keyboard units: + +```css +.vkb-wrapper { container-type: inline-size; } +.vkb { --u: calc(100cqi / var(--board-w)); } +``` + +Each key is absolutely positioned using per-key custom properties: + +```css +.key { + position: absolute; + left: calc(var(--kx) * var(--u)); + top: calc(var(--ky) * var(--u)); + width: calc(1 * var(--u) - var(--pad) * 2); /* overridden by .w-NNN classes */ + height: calc(var(--kh, 1) * var(--u) - var(--pad) * 2); +} +``` + +Width classes (e.g. `.w-150` for 1.5u) are mapped from a lookup table in `Keycap.tsx`. +Non-standard widths fall back to a `--key-w` inline custom property. + +### LED Indicators + +Lock key LED indicators (Caps Lock, Scroll Lock, Num Lock) are driven by +CSS classes on the `.vkb` container: `.caps-lock-on`, `.scroll-lock-on`, +`.num-lock-on`. When active, a green dot (`::before` pseudo-element) appears +in the top-right corner of the corresponding keycap, matched by +`data-scancode` attribute (57, 71, 83 respectively). The `vkbClassName` prop +on `VirtualKeyboard` is the injection point for these classes. + +--- + +## Built-in Layouts + +19 KLE JSON files are embedded in the binary via `go:embed` in `builtin.go`. +Layout IDs use hyphens (e.g. `en-US`, `de-DE`) to match existing device +config values. The file lookup converts hyphens to underscores for the +filename (e.g. `en-US` → `layouts/en_US.kle.json`). + +| ID | Type | Keys | Description | +|---|---|---|---| +| `en-US` | ANSI 104 | 104 | English (US) QWERTY | +| `en-UK` | ISO 105 | 105 | English (UK) | +| `cs-CZ` | ISO 105 | 105 | Czech QWERTZ | +| `da-DK` | ISO 105 | 105 | Danish | +| `de-CH` | ISO 105 | 105 | Swiss German QWERTZ | +| `de-DE` | ISO 105 | 105 | German QWERTZ | +| `es-ES` | ISO 105 | 105 | Spanish | +| `fr-BE` | ISO 105 | 105 | Belgian AZERTY | +| `fr-CH` | ISO 105 | 105 | Swiss French QWERTZ | +| `fr-FR` | ISO 105 | 105 | French AZERTY | +| `hu-HU` | ISO 105 | 105 | Hungarian QWERTZ | +| `it-IT` | ISO 105 | 105 | Italian | +| `ja-JP` | JIS 109 | 109 | Japanese | +| `nb-NO` | ISO 105 | 105 | Norwegian Bokmål | +| `nl-BE` | alias | — | Alias → `fr-BE` (Belgian AZERTY) | +| `pl-PL` | ISO 105 | 105 | Polish Programmers | +| `pt-PT` | ISO 105 | 105 | Portuguese | +| `ru-RU` | ISO 105 | 105 | Russian ЙЦУКЕН | +| `sl-SI` | ISO 105 | 105 | Slovenian QWERTZ | +| `sv-SE` | ISO 105 | 105 | Swedish | + +Each built-in layout that has dead keys includes an audited `deadKeys` array +in its KLE metadata. For example, de-DE declares ``["^", "´", "`"]`` and +fr-FR declares ``["^", "¨"]``. Layouts without dead keys (e.g. `en-US`, +`ru-RU`) omit the field entirely — this is load-bearing, not just cosmetic, +because the `deadKeys` declaration gates charMap composition generation. +Omitting it ensures paste treats `^`, `~`, etc. as direct-output keys. + +Special key legends use Unicode symbols: `⌫` (Backspace), `⏎` (Enter), +`⇥` (Tab), `⇪` (Caps Lock), `↑↓←→` (arrows). +Modifiers show: `⇧ Shift`, `⌃ Ctrl`, `⌥ Alt`, `⌘ Meta`, `☰ Menu`. +Numpad keys show plain characters (`1`, `+`, `/`) without a `KP` prefix. +Each layout has a locale ID decal (e.g. `en-US`) rendered as a non-interactive +label above the numpad area. + +--- + +## File Reference + +```text +├── docs/keyboard/ +│ ├── DESIGN.md ← this file +│ ├── TRANSPORT.md ← wire contract documentation +│ ├── ADDING_A_LAYOUT.md ← step-by-step contributor walkthrough +│ └── DEVELOPMENT.md ← engineer reference (overrides, compact form factors, dead-key auditing) +``` + +```text +├── ui/src/ +│ ├── components/keyboard/ ← pure rendering (no state management) +│ │ ├── types/ +│ │ │ └── schema.ts ← all TypeScript types (transport + KeyLayer) +│ │ ├── VirtualKeyboard.tsx ← pure renderer, derives layer from pressedScancodes +│ │ ├── Keycap.tsx ← memo'd keycap with localized aria labels +│ │ ├── LayoutPreviewDialog.tsx ← layout preview modal with interactive key flash +│ │ ├── useKleUpload.ts ← file upload hook (POSTs to Go) +│ │ └── virtual-keyboard.css ← all keyboard CSS (scoped under .vkb) +│ ├── components/ +│ │ ├── VirtualKeyboard.tsx ← KeyboardWrapper (state, latching, HID bridge) +│ │ ├── QuickActions.tsx ← common key combo dropdown (Ctrl+Alt+Del, etc.) +│ │ ├── textToMacroSteps.ts ← converts text strings to macro steps via charMap +│ │ ├── MacroForm.tsx ← macro editor with text-to-macro and keyboard picker +│ │ └── MacroStepCard.tsx ← individual macro step editor +│ └── components/popovers/ +│ └── PasteModal.tsx ← paste text using charMap + executeHidMacro +│ └── keyDisplayNames.ts ← buildKeyDisplayMap() + modifierDisplayMap for macro UI +``` + +```text +├── internal/keyboard/ +│ ├── keyboard.go ← ParseKLE(), types, charMap, dead key compositions +│ ├── scancode.go ← x/y position → HID Usage ID table; ScancodeProducesText / IsControlScancode +│ ├── keyaliases.go ← parses keyaliases.json; controlLegendDisplayMap, IsKnownSpecialLegend +│ ├── keyaliases.json ← shared taxonomy of special-key legends (Go + TS) +│ ├── handler.go ← HTTP upload + JSON-RPC handlers + builtinLayouts +│ ├── builtin.go ← go:embed for layouts/*.kle.json + alias handling +│ ├── keyboard_test.go ← table-driven tests + builtin layout validation + drift guard +│ └── layouts/ ← 19 KLE JSON files (ANSI/ISO/JIS) +``` + +### Design note: why Go parses, not the client + +KLE parsing, scancode inference, charMap building, and dead key composition +all happen on the Go backend (`internal/keyboard/`). The React client receives +the fully processed `KeyboardLayout` and acts purely as a renderer. There is +no client-side KLE parser, position-to-scancode table, or charMap builder. + +--- + +## References + +- [KLE format reference](https://github.com/ijprest/keyboard-layout-editor/wiki/Serialized-Data-Format) +- [HID Usage Table: USB HID Usage Tables 1.3, Keyboard/Keypad Page (0x07) for scancode reference](https://usb.org/sites/default/files/hut1_3_0.pdf) + +--- + +## Open Issues Addressed + +| GitHub Issue | Problem | Fix | +|---|---|---| +| #65 | Wrong chars from virtual keyboard on German target | Target layout KLE → correct scancode table | +| #47 | Layout mismatch guest/host | Virtual keyboard + paste use target charMap; physical keyboard uses position passthrough (same as hardware KVM) | +| #30 | AltGr combinations broken | Virtual keyboard has full AltGr layer support; physical keyboard passes AltGr position correctly | +| #223 | Virtual keyboard English only | KLE-driven renderer with layer switching for all 19 built-in layouts | +| #649 | Dvorak operator layout | Physical passthrough is correct KVM behaviour; virtual keyboard + paste provide character-accurate input | +| #1067 | Belgian FR layout missing | Built-in `fr-BE` layout + `nl-BE` alias | +| #1184 | Hungarian layout — contributor has code but can't submit | KLE upload path + built-in `hu-HU` + GitHub issue template | + +--- + +## UI Languages Without a Dedicated Keyboard Layout + +Some UI localization languages do not have a separate built-in keyboard layout +because their input method does not require one: + +| UI language | Recommended layout | Reason | +|---|---|---| +| zh (Chinese Simplified) | `en-US` | Chinese input uses an OS-level IME on a standard QWERTY keyboard | +| zh-tw (Chinese Traditional) | `en-US` | Traditional Chinese uses IME (Zhuyin/Bopomofo is an IME overlay, not a physical layout) | +| cy (Welsh) | `en-UK` | Welsh uses the standard UK physical keyboard | + +These users should select the recommended layout in the keyboard settings. +No KLE file is needed — the physical keys are identical to the recommended layout. + +--- + +## What Is Not In Scope + +- **Mac boot picker fix (#1070)** — requires USB HID descriptor change in `usb.go`, not a layout issue +- **Client-native copy-paste (#735)** — requires OS-level clipboard API permissions on the target, a separate workstream diff --git a/docs/keyboard/DEVELOPMENT.md b/docs/keyboard/DEVELOPMENT.md new file mode 100644 index 000000000..fa7bbf152 --- /dev/null +++ b/docs/keyboard/DEVELOPMENT.md @@ -0,0 +1,86 @@ +# JetKVM Virtual Keyboard — Development Guide + +> **Purpose:** Reference for engineers working on the keyboard subsystem internals (parser, charMap, scancode tables). +> +> **Adding a new layout?** See **[ADDING_A_LAYOUT.md](ADDING_A_LAYOUT.md)** — a step-by-step contributor walkthrough. The summary below is just a reminder of the moving parts. + +--- + +## Adding a Layout (Quick Reference) + +For the full step-by-step guide, see **[ADDING_A_LAYOUT.md](ADDING_A_LAYOUT.md)**. + +Quick summary: + +1. Drop a new `.kle.json` into `internal/keyboard/layouts/`. Filename uses underscores (`de_DE`); the layout ID uses hyphens (`de-DE`) — converted automatically. +2. First element of the JSON is the metadata block (`name`, `author`, optional `deadKeys`, optional `kbdLayoutInfo`). +3. Auto-discovered via `go:embed`; no registration code to write. Aliases live in `layoutAliases` in `builtin.go`. +4. Validate with `go test ./internal/keyboard/...` and `go run ./scripts/audit_layouts.go `. + +--- + +## Scancode Overrides for Non-Standard Layouts + +For layouts where keys are in non-standard physical positions (split keyboards, ortholinear, custom PCBs), position-based scancode inference may produce incorrect results. The `scancodes` metadata field provides per-key overrides: + +```json +{ + "name": "My Custom Split", + "scancodes": { "42": 76, "55": 83 } +} +``` + +The key index is 0-based in parse order (the order keys appear in the KLE JSON, left-to-right, top-to-bottom). The value is the USB HID Usage ID (decimal). + +To find the correct key index: + +1. Open the KLE JSON and count keys from the beginning (starting at 0) +2. Property objects (width, gap, etc.) do not count as keys +3. Only legend strings increment the key counter + +Common HID Usage IDs for overrides: + +| Key | HID Usage ID (decimal) | HID Usage ID (hex) | +|---|---|---| +| Delete | 76 | 0x4C | +| Insert | 73 | 0x49 | +| Home | 74 | 0x4A | +| End | 77 | 0x4D | +| Page Up | 75 | 0x4B | +| Page Down | 78 | 0x4E | +| Num Lock | 83 | 0x53 | +| Print Screen | 70 | 0x46 | + +--- + +## Compact Layout Support + +The parser supports **75% and TKL** compact form factors using a separate +position table without the y:0.5 gap. Selection criteria: + +- **Full-size:** `boardW > 20` or `keyCount >= 100` +- **Compact (75%/TKL):** `boardW <= 20`, `keyCount < 100`, `boardH >= 6` +- **60%/65%:** Falls back to full-size table (most keys get `scancode=0`). + These layouts require `scancodes` metadata overrides for every key. + +If the compact table maps a few edge-case keys incorrectly, use `scancodes` +metadata overrides to fix them rather than modifying the table (which would +affect all compact layouts). + +--- + +## Dead Key Auditing + +When adding a layout with dead keys, verify the `deadKeys` metadata against the operating system's actual dead key behavior: + +1. Open the OS keyboard settings for the target layout +2. Identify which keys produce a dead key (no character until the next key) +3. List exactly those legend characters in the `deadKeys` array +4. Do **not** include characters that merely look like accents but type directly (e.g. on some layouts `~` types immediately rather than acting as a dead key) + +The `deadKeys` metadata gates **both** the CSS dead key indicator **and** charMap composition generation. Layouts without `deadKeys` produce no compositions — this is critical because characters like `^` and `~` are normal direct-output keys on many layouts (e.g. en-US). Getting this wrong would cause paste to send dead key sequences where the target OS expects direct output (e.g. `^a` instead of `â`). + +## References + +- [KLE format reference](https://github.com/ijprest/keyboard-layout-editor/wiki/Serialized-Data-Format) +- [HID Usage Table: USB HID Usage Tables 1.3, Keyboard/Keypad Page (0x07) for scancode reference](https://usb.org/sites/default/files/hut1_3_0.pdf) \ No newline at end of file diff --git a/docs/keyboard/TRANSPORT.md b/docs/keyboard/TRANSPORT.md new file mode 100644 index 000000000..6abb74939 --- /dev/null +++ b/docs/keyboard/TRANSPORT.md @@ -0,0 +1,274 @@ +# KLE Transport Schema + +> **Just want to add a layout?** See **[ADDING_A_LAYOUT.md](ADDING_A_LAYOUT.md)** for the step-by-step contributor guide. This document is the wire-format reference. + +## Overview + +KLE JSON files are parsed **on the Go backend** (the KVM device). The React client receives a single processed `KeyboardLayout` JSON object via JSON-RPC and uses it directly for rendering and HID dispatch. The client does zero parsing and zero scancode inference. + +## Flow + +```text +User uploads .json (KLE format) + │ + ▼ +POST /keyboard/upload (raw JSON body, requires auth) + │ + ▼ +Go: ParseKLE(rawJSON) internal/keyboard/keyboard.go + │ + ├─→ validate + ├─→ build []TransportKey (positions, legends, scancodes, shape) + ├─→ build CharMap (char → scancode+modifiers, for paste) + └─→ addDeadKeyCompositions (Unicode NFC → prefixed HIDCombos) + │ + ▼ +Store as /userdata/kvm_layouts/.layout.json + │ + ▼ +JSON-RPC: getKeyboardLayoutData(id) → KeyboardLayout (transport type) + │ + ▼ +React: (render only) + PasteModal uses layout.charMap (paste with dead key support) + Macro UI uses buildKeyDisplayMap() (key display names) +``` + +Built-in layouts (19 KLE files) are embedded in the binary via `go:embed` +and served through the same `getKeyboardLayoutData` RPC. Layout IDs use +hyphens (`en-US`, `de-DE`) matching existing device config values; the +file lookup converts to underscores (`en_US.kle.json`). + +### KLE Metadata Extensions + +The optional metadata object (first element of the KLE array) supports two +JetKVM-specific fields in addition to the standard KLE `name`, `author`, etc.: + +| Field | Type | Default | Purpose | +|---|---|---|---| +| `deadKeys` | `string[]` | `[]` (none) | Legend characters that are dead keys. Gates **both** the `deadLegends` list on each affected `TransportKey` (the frontend applies the `.dead` CSS class to the matching legend slot) **and** charMap composition generation via Unicode NFC. Layouts without this field produce no dead key compositions — characters like `^` and `~` are treated as normal keys that output directly. | +| `scancodes` | `Record` | `{}` (none) | Maps key index (0-based, parse order) to a USB HID Usage ID, overriding position-based inference. Use for non-standard layouts where keys are in unusual positions. | + +**Example** (German QWERTZ with dead keys and a hypothetical scancode override): + +```json +[ + { + "name": "Deutsch de-DE (ISO 105)", + "author": "JetKVM", + "deadKeys": ["^", "´", "`"], + "scancodes": { "42": 76 } + }, + ["Esc", {"x": 1}, "F1", "F2", "..."], + ... +] +``` + +In this example, keys whose normal legend is `^`, `´`, or `` ` `` get the dead +key visual indicator, and key #42 (0-based) is forced to scancode 76 (Delete) +regardless of its physical position. + +## Why Parse on the Go Side + +1. **Single parse:** layout is parsed once on upload, stored as the processed + format, served cheaply on every client connect. +2. **Testable in isolation:** The KLE parser has comprehensive table-driven + tests covering edge cases (ISO enter, stepped caps, dead key compositions, + auto-case, all built-in layouts). +3. **Validation owns errors:** parse errors surface at upload time with clear + messages, not silently at render time in the browser. +4. **The client is a renderer:** React only needs to know *what to draw* and + *what scancode to send*. It should not know *how* KLE encodes key widths. + +## Transport Type: `KeyboardLayout` + +Defined in full in `ui/src/components/keyboard/types/schema.ts` (TypeScript) +and `internal/keyboard/keyboard.go` (Go). The schema file contains both +transport types and the client-side `KeyLayer` type. Both sides must stay in +sync — the JSON field names are the contract. + +### Top Level + +```jsonc +{ + "id": "de-DE", // unique identifier (built-in) or UUID (uploaded) + "name": "German (QWERTZ)", // display name, from KLE meta or user-provided + "author": "JetKVM", // from KLE meta.author, optional + "boardW": 22.0, // total width in keyboard units + "boardH": 6.0, // total height in keyboard units + "keys": [ /* []TransportKey */ ], + "charMap": { /* Record */ } +} +``` + +### `TransportKey` + +```jsonc +{ + // Position & size (in keyboard units, from KLE) + "x": 0.0, + "y": 1.0, + "w": 1.0, // width, default 1 + "h": 1.0, // height, default 1 + "w2": 1.5, // second rect width (ISO enter etc.), omitted if absent + "h2": 1.0, // second rect height, omitted if absent + "x2": -0.25, // second rect x offset, omitted if absent + "y2": 0.0, // second rect y offset, omitted if absent + + // Shape class — computed by Go, consumed directly as CSS class by React. + // One of: "" | "iso-enter" | "big-ass-enter" | "stepped-caps" + "shape": "iso-enter", + + // Legends — only present layers included; absent = key has no legend for that layer. + // The Go parser auto-generates case pairs for single-letter keys (e.g. "Q" → normal: "q", shift: "Q"). + "legends": { + "normal": "1", + "shift": "!", + "altgr": "²", + "shiftAltgr": "¹" + }, + + // USB HID Usage ID (0x07 page). 0 = non-typeable (modifier, etc.) + "scancode": 30, + + // Names of the legend slots on this key whose legend matches a declared + // dead key character (from KLE metadata's `deadKeys`). Slot names are + // 'normal', 'shift', 'altgr', 'shift-altgr', 'kana', 'shift-kana'. The + // frontend applies the `.dead` CSS class to each listed slot. Omitted + // when no slot is a dead key. + "deadLegends": ["normal"], + + // Whether this key has a homing bump (KLE "n" property) + "homing": false, + + // Whether this key is a decal / non-functional label (KLE "d" property) + "decal": false, + + // Control-like classification — see "Scancode classification" below. + "controlLike": false, + + // KLE colorway (optional — only present if KLE file specifies per-key colors) + "color": "#2d2d2d", + "textColor": "#e0e0e0" +} +``` + +#### Scancode classification (`controlLike`) + +The Go backend owns scancode classification. Two helpers in +`internal/keyboard/scancode.go`: + +- `ScancodeProducesText(sc)` — true for keys that type a character + (letters, digits, printable punctuation, the ISO key, printable numpad + keys). False for Enter, Escape, Backspace, Tab, NumLock, KPEnter. +- `IsControlScancode(sc)` — the complement, plus an explicit list of + "looks-like-text-but-render-as-control" keys (notably Space, which + types a character but takes the meta-control CSS class on the keycap). + +`ParseKLE` evaluates `IsControlScancode(Scancode)` once per key and +stamps the result onto `TransportKey.controlLike`. The frontend reads +the field directly and has no classifier of its own — any classification +logic lives only in Go, with unit tests in +`TestScancodeClassificationContract` in `keyboard_test.go`. + +### `HIDCombo` (values in `charMap`) + +```jsonc +{ + "s": 30, // scancode (USB HID Usage ID) + "m": 0, // modifiers byte: 0=none, 2=Shift, 64=AltGr, 66=Shift+AltGr + "p": { // dead key prefix (optional — only for composed/dead key characters) + "s": 47, // send this key first (the dead key) + "m": 0 // with these modifiers + } +} +``` + +Short field names (`s`, `m`, `p`) are intentional — `charMap` can have 200+ +entries and the layout is served on every session connect. + +For simple characters, `p` is omitted. For dead key compositions: + +- `"â"` → `{s: 4, m: 0, p: {s: 47, m: 0}}` — press `^` (dead), then `a` +- `"^"` → `{s: 0x2C, m: 0, p: {s: 47, m: 0}}` — press `^` (dead), then Space + +### Modifier byte constants (same as HID spec) + +| Constant | Value | Meaning | +|-----------------|-------|----------------| +| MOD_NONE | 0x00 | No modifier | +| MOD_LSHIFT | 0x02 | Left Shift | +| MOD_ALTGR | 0x40 | Right Alt | +| MOD_SHIFT_ALTGR | 0x42 | Shift + AltGr | + +## JSON-RPC Methods + +### `getKeyboardLayouts() → []LayoutMeta` + +Returns the list of available layouts (built-in + uploaded). Does not include +the full key data — just enough to populate the settings dropdown. + +```jsonc +[ + { "id": "en-US", "name": "English (US)", "builtin": true }, + { "id": "de-DE", "name": "German (QWERTZ)", "builtin": true }, + { "id": "fr-FR", "name": "French (AZERTY)", "builtin": true }, + { "id": "uuid-abc123", "name": "My Layout", "builtin": false } +] +``` + +### `getKeyboardLayoutData(id: string) → KeyboardLayout` + +Returns the full transport type for the given layout ID. +Called once when the session starts (or when the user changes layout in settings). +If `id` is unknown (deleted, corrupted, missing from config) the handler falls +back to `en-US` so the UI always has a usable keyboard. + +### `getKeyboardLayout() → string` + +Returns the active layout ID string from device config (e.g. `"de-DE"`). +This is the lightweight counterpart to `getKeyboardLayoutData` — use it when +you only need to know which layout is selected. + +### `setKeyboardLayout(layout: string) → void` + +Updates the active layout ID and persists it to the device config. +The layout ID must be a built-in layout or a previously-uploaded user layout. + +### `deleteKeyboardLayout(id: string) → void` + +Deletes a user-uploaded layout. Returns an error if `id` is a built-in layout. + +## HTTP Upload Endpoint + +KLE files can be large-ish raw JSON. Upload via HTTP rather than JSON-RPC +to avoid base64 overhead and to get streaming parse. This endpoint requires +authentication (it is behind the `protected` route group in `web.go`). + +```text +POST /keyboard/upload +Content-Type: application/json (raw KLE JSON body) + +Query params: + ?name=My+Layout optional display name override + ?id=existing-id optional: replace an existing user-uploaded layout + (cannot replace a built-in layout — request is rejected) + +Response 200: + { + "id": "uuid-abc123", + "name": "My Layout", + "keyCount": 87, + "warnings": ["3 of 87 keys have no HID scancode (97% coverage)..."] + } + + The `warnings` array is omitted when empty. Warnings are non-fatal — the + layout is stored and usable, but the user should review the issues (e.g. + unmapped keys, low charMap coverage, unsupported form factor). + +Response 400: + { "error": "KLE parse error: row 3 contains invalid property object" } +``` + +The upload endpoint calls the same `ParseKLE()` function internally and +stores the resulting `KeyboardLayout` JSON to `/userdata/kvm_layouts/`. diff --git a/internal/keyboard/builtin.go b/internal/keyboard/builtin.go new file mode 100644 index 000000000..cd08a8d79 --- /dev/null +++ b/internal/keyboard/builtin.go @@ -0,0 +1,100 @@ +package keyboard + +import ( + "embed" + "encoding/json" + "fmt" + "path" + "strings" +) + +//go:embed layouts/*.kle.json +var layoutFS embed.FS + +// layoutAliases maps alternative IDs to the actual KLE filename stem. +// Used when the canonical config ID doesn't match the file name. +var layoutAliases = map[string]string{ + "nl-BE": "fr_BE", // Belgian AZERTY — isoCode was "nl-BE" in the old system +} + +// builtinLayouts is computed once at startup by scanning the embedded +// layouts/ directory. Each .kle.json file becomes a hyphenated ID +// (e.g. en_US.kle.json → "en-US"). Aliases are added on top. +var builtinLayouts = discoverBuiltinLayouts() + +func discoverBuiltinLayouts() map[string]struct{} { + layouts := make(map[string]struct{}) + + entries, err := layoutFS.ReadDir("layouts") + if err != nil { + return layouts + } + + const suffix = ".kle.json" + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, suffix) { + continue + } + // en_US.kle.json → "en-US" + stem := strings.TrimSuffix(name, suffix) + id := strings.ReplaceAll(stem, "_", "-") + layouts[id] = struct{}{} + } + + // Add aliases + for alias := range layoutAliases { + layouts[alias] = struct{}{} + } + + return layouts +} + +// The canonical layout IDs use hyphens (e.g. "en-US") to match existing device +// configs, while the KLE files on disk use underscores (en_US.kle.json). +// This function handles the conversion, plus any aliases. +func builtinLayoutFilename(id string) string { + // Check for aliases first (e.g. nl-BE -> fr_BE) + if alias, ok := layoutAliases[id]; ok { + return path.Join("layouts", alias+".kle.json") + } + // Convert hyphens to underscores: "en-US" -> "en_US" + fileStem := strings.ReplaceAll(id, "-", "_") + return path.Join("layouts", fileStem+".kle.json") +} + +func loadBuiltinLayoutFromFS(id string) (*KeyboardLayout, error) { + filename := builtinLayoutFilename(id) + data, err := layoutFS.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("built-in layout not found: %s", id) + } + return ParseKLE(data, id, "") +} + +// loadBuiltinLayoutMetaFromFS reads only layout metadata needed for the +// settings list (ID + name), avoiding full ParseKLE processing. +func loadBuiltinLayoutMetaFromFS(id string) (LayoutMeta, error) { + filename := builtinLayoutFilename(id) + data, err := layoutFS.ReadFile(filename) + if err != nil { + return LayoutMeta{}, fmt.Errorf("built-in layout not found: %s", id) + } + + var top []json.RawMessage + if err := json.Unmarshal(data, &top); err != nil { + return LayoutMeta{}, fmt.Errorf("invalid built-in layout JSON: %s: %w", id, err) + } + + name := id + if len(top) > 0 { + var meta kleMetadata + if err := json.Unmarshal(top[0], &meta); err == nil { + if meta.Name != "" { + name = sanitizeName(meta.Name) + } + } + } + + return LayoutMeta{ID: id, Name: name, Builtin: true}, nil +} diff --git a/internal/keyboard/handler.go b/internal/keyboard/handler.go new file mode 100644 index 000000000..61e40e922 --- /dev/null +++ b/internal/keyboard/handler.go @@ -0,0 +1,285 @@ +package keyboard + +// HTTP handler for KLE layout upload and JSON-RPC handlers for layout management. +// +// Wire these into web.go / jsonrpc.go: +// +// JSON-RPC (add to rpcHandlers map in jsonrpc.go): +// "getKeyboardLayouts": {Func: keyboard.RpcGetKeyboardLayouts}, +// "getKeyboardLayoutData": {Func: keyboard.RpcGetKeyboardLayoutData, Params: []string{"id"}}, +// "deleteKeyboardLayout": {Func: keyboard.RpcDeleteKeyboardLayout, Params: []string{"id"}}, + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + "sync" + + "github.com/gin-gonic/gin" +) + +const ( + layoutsDir = "/userdata/kvm_layouts" + maxUploadBytes = 64 * 1024 // 64 KB + maxUploadKB = maxUploadBytes / 1024 +) + +// HandleKeyboardUpload parses an uploaded KLE JSON file and stores the +// resulting KeyboardLayout. +// +// Body: raw KLE JSON (Content-Type: application/json) +// Query params: +// +// ?name=My+Layout optional display name override +// ?id=existing-id optional: replace an existing user-uploaded layout +// (cannot replace built-in layouts) +// +// Response 200: LayoutUploadResponse JSON +// Response 400: { "error": "..." } +func HandleKeyboardUpload(c *gin.Context) { + // Read body with size limit + body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxUploadBytes+1)) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("failed to read body: %v", err)}) + return + } + if len(body) > maxUploadBytes { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("file too large (max %d KB)", maxUploadKB)}) + return + } + + nameOverride := c.Query("name") + idOverride := c.Query("id") + + // Block replacing built-in layouts + if idOverride != "" { + if _, ok := builtinLayouts[idOverride]; ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "cannot replace built-in layout"}) + return + } + } + + layout, err := ParseKLE(body, idOverride, nameOverride) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + warnings := collectLayoutWarnings(layout) + + if err := storeLayout(layout); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to store layout: %v", err)}) + return + } + invalidateLayoutListCache() + + c.JSON(http.StatusOK, LayoutUploadResponse{ + ID: layout.ID, + Name: layout.Name, + KeyCount: len(layout.Keys), + Warnings: warnings, + }) +} + +// --------------------------------------------------------------------------- +// Storage helpers +// --------------------------------------------------------------------------- + +func layoutPath(id string) string { + // Sanitise ID — only allow alphanumeric, hyphens, underscores + safe := strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || r == '-' || r == '_' { + return r + } + return '_' + }, id) + return filepath.Join(layoutsDir, safe+".layout.json") +} + +func storeLayout(layout *KeyboardLayout) error { + if err := os.MkdirAll(layoutsDir, 0755); err != nil { + return err + } + data, err := json.Marshal(layout) + if err != nil { + return err + } + return os.WriteFile(layoutPath(layout.ID), data, 0644) +} + +func loadLayout(id string) (*KeyboardLayout, error) { + // Try user-uploaded first, then built-in + path := layoutPath(id) + data, err := os.ReadFile(path) + if err != nil { + // Try built-in (embedded in binary via go:embed in a separate file) + return loadBuiltinLayout(id) + } + var layout KeyboardLayout + if err := json.Unmarshal(data, &layout); err != nil { + return nil, fmt.Errorf("corrupt layout file %s: %w", id, err) + } + return &layout, nil +} + +// Built-in layouts are embedded in builtin.go via go:embed. + +// loadBuiltinLayout reads a built-in layout from the embedded KLE files. +var loadBuiltinLayout = loadBuiltinLayoutFromFS + +// loadBuiltinLayoutMeta reads LayoutMeta data (id/name) for built-in layouts +var loadBuiltinLayoutMeta = loadBuiltinLayoutMetaFromFS + +var layoutListCache struct { + mu sync.RWMutex + layouts []LayoutMeta + ready bool +} + +func cloneLayoutMetas(layouts []LayoutMeta) []LayoutMeta { + cloned := make([]LayoutMeta, len(layouts)) + copy(cloned, layouts) + return cloned +} + +func getLayoutListCache() ([]LayoutMeta, bool) { + layoutListCache.mu.RLock() + defer layoutListCache.mu.RUnlock() + if !layoutListCache.ready { + return nil, false + } + return cloneLayoutMetas(layoutListCache.layouts), true +} + +func setLayoutListCache(layouts []LayoutMeta) { + layoutListCache.mu.Lock() + defer layoutListCache.mu.Unlock() + layoutListCache.layouts = cloneLayoutMetas(layouts) + layoutListCache.ready = true +} + +func invalidateLayoutListCache() { + layoutListCache.mu.Lock() + defer layoutListCache.mu.Unlock() + layoutListCache.layouts = nil + layoutListCache.ready = false +} + +func loadStoredLayoutMeta(id string) (LayoutMeta, error) { + path := layoutPath(id) + data, err := os.ReadFile(path) + if err != nil { + return LayoutMeta{}, err + } + var stored struct { + Name string `json:"name"` + } + if err := json.Unmarshal(data, &stored); err != nil { + return LayoutMeta{}, fmt.Errorf("corrupt layout file %s: %w", id, err) + } + return LayoutMeta{ID: id, Name: stored.Name, Builtin: false}, nil +} + +func buildKeyboardLayoutsList() []LayoutMeta { + var builtins []LayoutMeta + + // Built-ins first (lightweight metadata only) + for id := range builtinLayouts { + meta, err := loadBuiltinLayoutMeta(id) + if err != nil { + continue // built-in not yet embedded — skip gracefully + } + builtins = append(builtins, meta) + } + + // Sort built-ins by name + slices.SortFunc(builtins, func(a, b LayoutMeta) int { + return strings.Compare(a.Name, b.Name) + }) + + layouts := cloneLayoutMetas(builtins) + + // User-uploaded (appended after sorted built-ins) + entries, err := os.ReadDir(layoutsDir) + if err != nil && !os.IsNotExist(err) { + return layouts // return built-ins even if dir missing + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".layout.json") { + continue + } + id := strings.TrimSuffix(entry.Name(), ".layout.json") + if _, ok := builtinLayouts[id]; ok { + continue // already included above + } + meta, err := loadStoredLayoutMeta(id) + if err != nil { + continue + } + layouts = append(layouts, meta) + } + + return layouts +} + +// --------------------------------------------------------------------------- +// JSON-RPC handlers +// --------------------------------------------------------------------------- + +// RpcGetKeyboardLayouts returns the list of available layouts. +// Maps to JSON-RPC method "getKeyboardLayouts". +func RpcGetKeyboardLayouts() ([]LayoutMeta, error) { + if cached, ok := getLayoutListCache(); ok { + return cached, nil + } + + layouts := buildKeyboardLayoutsList() + setLayoutListCache(layouts) + return layouts, nil +} + +const fallbackLayoutID = "en-US" + +// RpcGetKeyboardLayoutData returns the full KeyboardLayout for a given ID. +// If the requested layout is not found (deleted, corrupted, or invalid config), +// it falls back to the en-US built-in layout to ensure the UI always has a +// usable keyboard. +// Maps to JSON-RPC method "getKeyboardLayoutData". +func RpcGetKeyboardLayoutData(id string) (*KeyboardLayout, error) { + if id == "" { + id = fallbackLayoutID + } + layout, err := loadLayout(id) + if err != nil && id != fallbackLayoutID { + // Requested layout not found — fall back to en-US + return loadLayout(fallbackLayoutID) + } + return layout, err +} + +// RpcDeleteKeyboardLayout deletes a user-uploaded layout. +// Returns an error if the id is a built-in layout. +// Maps to JSON-RPC method "deleteKeyboardLayout". +func RpcDeleteKeyboardLayout(id string) error { + if id == "" { + return fmt.Errorf("id parameter required") + } + if _, ok := builtinLayouts[id]; ok { + return fmt.Errorf("cannot delete built-in layout: %s", id) + } + path := layoutPath(id) + if err := os.Remove(path); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("layout not found: %s", id) + } + return fmt.Errorf("failed to delete layout: %w", err) + } + invalidateLayoutListCache() + return nil +} diff --git a/internal/keyboard/keyaliases.go b/internal/keyboard/keyaliases.go new file mode 100644 index 000000000..6b7fef679 --- /dev/null +++ b/internal/keyboard/keyaliases.go @@ -0,0 +1,107 @@ +package keyboard + +import ( + _ "embed" + "encoding/json" + "fmt" + "regexp" +) + +// SpecialKey is one entry in the canonical key-alias taxonomy. +// +// The taxonomy is the single source of truth for which legend strings on a +// non-text key (or Space) refer to which logical key. Both the Go keycap +// display normalization (controlLegendDisplayMap) and the TypeScript aria-label +// resolution (KEY_ARIA_NAMES in Keycap.tsx) are derived from this data. +type SpecialKey struct { + // AriaKey is the suffix used by the frontend to look up the localized aria + // name (m.keys_()). + AriaKey string `json:"ariaKey"` + // Canonical is the preferred display form for keycaps. + Canonical string `json:"canonical"` + // Aliases are alternate legend strings that should be normalized to + // Canonical for display purposes. The Canonical itself is implicitly + // recognized and need not appear here. + Aliases []string `json:"aliases"` +} + +//go:embed keyaliases.json +var keyAliasesJSON []byte + +// SpecialKeys is the parsed taxonomy. Read-only after package load. +// +// PassthroughLegendPattern matches multi-character legends that are +// self-explanatory across keyboards (e.g. F1–F24) and need no aria translation. +// +// controlLegendDisplayMap is the alias→canonical lookup used by +// normalizeControlLegendsForDisplay; built from SpecialKeys. +// +// All three are initialised together at package load time via parseKeyAliases. +// A parse failure during var-initializer panics: the JSON is embedded in the +// binary, so a failure here means a broken build, not a recoverable runtime error. +var ( + SpecialKeys []SpecialKey + PassthroughLegendPattern *regexp.Regexp + controlLegendDisplayMap map[string]string + + _ = func() struct{} { + SpecialKeys, PassthroughLegendPattern, controlLegendDisplayMap = parseKeyAliases(keyAliasesJSON) + return struct{}{} + }() +) + +// IsKnownSpecialLegend reports whether legend is recognized by the taxonomy: +// either as a canonical form, a declared alias, or a passthrough match (F-keys). +func IsKnownSpecialLegend(legend string) bool { + if _, ok := controlLegendDisplayMap[legend]; ok { + return true + } + if PassthroughLegendPattern != nil && PassthroughLegendPattern.MatchString(legend) { + return true + } + return false +} + +func parseKeyAliases(raw []byte) ([]SpecialKey, *regexp.Regexp, map[string]string) { + var doc struct { + SpecialKeys []SpecialKey `json:"specialKeys"` + PassthroughLegendPattern string `json:"passthroughLegendPattern"` + } + if err := json.Unmarshal(raw, &doc); err != nil { + panic(fmt.Sprintf("keyaliases.json: parse failed: %v", err)) + } + + display := make(map[string]string, len(doc.SpecialKeys)*4) + + // Verify uniqueness as we build: every alias and canonical must map to + // exactly one SpecialKey. Duplicates would silently corrupt the lookup. + for _, sk := range doc.SpecialKeys { + if sk.AriaKey == "" || sk.Canonical == "" { + panic(fmt.Sprintf("keyaliases.json: entry with empty ariaKey or canonical: %+v", sk)) + } + if existing, dup := display[sk.Canonical]; dup { + if existing == sk.Canonical { + panic(fmt.Sprintf("keyaliases.json: canonical %q declared by multiple entries (duplicate ariaKey %q)", sk.Canonical, sk.AriaKey)) + } + panic(fmt.Sprintf("keyaliases.json: canonical %q collides with alias of %q", sk.Canonical, existing)) + } + display[sk.Canonical] = sk.Canonical + for _, alias := range sk.Aliases { + if existing, dup := display[alias]; dup { + panic(fmt.Sprintf("keyaliases.json: alias %q used by both %q and %q", alias, existing, sk.Canonical)) + } + display[alias] = sk.Canonical + } + } + + var pattern *regexp.Regexp + if doc.PassthroughLegendPattern != "" { + re, err := regexp.Compile(doc.PassthroughLegendPattern) + if err != nil { + panic(fmt.Sprintf("keyaliases.json: invalid passthroughLegendPattern: %v", err)) + } + pattern = re + } + + return doc.SpecialKeys, pattern, display +} diff --git a/internal/keyboard/keyaliases.json b/internal/keyboard/keyaliases.json new file mode 100644 index 000000000..688af8413 --- /dev/null +++ b/internal/keyboard/keyaliases.json @@ -0,0 +1,150 @@ +{ + "specialKeys": [ + { + "ariaKey": "alt", + "canonical": "Alt", + "aliases": ["⌥", "⇮", "⌥ Alt", "LAlt", "RAlt"] + }, + { + "ariaKey": "altgr", + "canonical": "AltGr", + "aliases": [] + }, + { + "ariaKey": "application", + "canonical": "App", + "aliases": ["Application"] + }, + { + "ariaKey": "arrow_down", + "canonical": "↓", + "aliases": ["▼", "Down", "ArrowDown", "Arrow Down"] + }, + { + "ariaKey": "arrow_left", + "canonical": "←", + "aliases": ["◀", "Left", "ArrowLeft", "Arrow Left"] + }, + { + "ariaKey": "arrow_right", + "canonical": "→", + "aliases": ["▶", "Right", "ArrowRight", "Arrow Right"] + }, + { + "ariaKey": "arrow_up", + "canonical": "↑", + "aliases": ["▲", "Up", "ArrowUp", "Arrow Up"] + }, + { + "ariaKey": "backspace", + "canonical": "⌫", + "aliases": ["⟵", "␈", "BS", "Backspace"] + }, + { + "ariaKey": "caps_lock", + "canonical": "Caps Lock", + "aliases": ["⇪", "🄰", "🅰", "⇬", "Caps", "CapsLock"] + }, + { + "ariaKey": "command", + "canonical": "⌘ Meta", + "aliases": ["⌘", "Command"] + }, + { + "ariaKey": "control", + "canonical": "Ctrl", + "aliases": ["⌃", "⌃ Ctrl", "✲", "⎈", "LCtrl", "RCtrl", "Control"] + }, + { + "ariaKey": "delete", + "canonical": "Del", + "aliases": ["␡", "⌦", "Delete"] + }, + { + "ariaKey": "end", + "canonical": "End", + "aliases": ["⤓"] + }, + { + "ariaKey": "enter", + "canonical": "⏎", + "aliases": ["↵", "↩", "⮠", "⌤", "⎆", "␍", "␊", "CR", "Enter"] + }, + { + "ariaKey": "escape", + "canonical": "Esc", + "aliases": ["⎋", "␛", "Escape"] + }, + { + "ariaKey": "home", + "canonical": "Home", + "aliases": ["⤒"] + }, + { + "ariaKey": "insert", + "canonical": "Ins", + "aliases": ["⎀", "Insert"] + }, + { + "ariaKey": "menu", + "canonical": "☰ Menu", + "aliases": ["☰", "▤", "▤ Menu", "Menu"] + }, + { + "ariaKey": "meta", + "canonical": "Win", + "aliases": ["❖", "◆", "⊞", "LWin", "RWin", "Super", "Meta", "Windows"] + }, + { + "ariaKey": "num_lock", + "canonical": "Num Lock", + "aliases": ["⇭", "Num", "NumLk"] + }, + { + "ariaKey": "option", + "canonical": "Option", + "aliases": [] + }, + { + "ariaKey": "page_down", + "canonical": "PgDn", + "aliases": ["⇟", "PageDown", "Page Down"] + }, + { + "ariaKey": "page_up", + "canonical": "PgUp", + "aliases": ["⇞", "PageUp", "Page Up"] + }, + { + "ariaKey": "pause", + "canonical": "Pause", + "aliases": [] + }, + { + "ariaKey": "print_screen", + "canonical": "PrtSc", + "aliases": ["⎙", "PrintScreen", "Print Screen"] + }, + { + "ariaKey": "scroll_lock", + "canonical": "Scroll Lock", + "aliases": ["⇳", "ScrLk", "ScrollLock"] + }, + { + "ariaKey": "shift", + "canonical": "Shift", + "aliases": ["⇧", "⇧ Shift", "Shift ⇧", "LShift", "RShift"] + }, + { + "ariaKey": "space", + "canonical": "Space", + "aliases": ["␣", "␠", "SP"] + }, + { + "ariaKey": "tab", + "canonical": "⭾", + "aliases": ["⇥", "⇄", "␉", "HT", "Tab"] + } + ], + "passthroughLegendPattern": "^F([1-9]|1[0-9]|2[0-4])$" +} diff --git a/internal/keyboard/keyboard.go b/internal/keyboard/keyboard.go new file mode 100644 index 000000000..7d8f058cb --- /dev/null +++ b/internal/keyboard/keyboard.go @@ -0,0 +1,1064 @@ +// KLE (keyboard-layout-editor.com) JSON parser and transport type definitions. +// +// This: +// 1. Parses raw KLE JSON into a KeyboardLayout struct +// 2. Infers USB HID scancodes from physical key positions +// 3. Builds a character→HID map for the paste text system +// 4. Validates the resulting layout +// 5. Serialises to JSON for storage and RPC transport +// +// The KeyboardLayout struct (and its JSON representation) is the wire contract +// with the React frontend. Field names must match transport/schema.ts. +// +// KLE format reference: +// https://github.com/ijprest/keyboard-layout-editor/wiki/Serialized-Data-Format +// HID Usage Table: USB HID Usage Tables 1.3, Keyboard/Keypad Page (0x07) for scancode reference: +// https://usb.org/sites/default/files/hut1_3_0.pdf + +package keyboard + +import ( + "cmp" + "encoding/json" + "fmt" + "math" + "math/bits" + "slices" + "strings" + "unicode" + "unicode/utf8" + + "github.com/google/uuid" + "golang.org/x/text/unicode/norm" +) + +const ( + ModNone uint8 = 0x00 + ModLCtrl uint8 = 0x01 + ModLShift uint8 = 0x02 + ModLAlt uint8 = 0x04 + ModLMeta uint8 = 0x08 + ModRCtrl uint8 = 0x10 + ModRShift uint8 = 0x20 + ModRAlt uint8 = 0x40 + ModRMeta uint8 = 0x80 + ModAltGr uint8 = ModRAlt + ModShiftAltGr uint8 = ModLShift | ModAltGr +) + +// All of the field names and enumerations in JSON are single characters for minimal transport size. + +// HIDCombo is a single USB HID key event: Usage ID + modifier byte. +// For dead key compositions, Prefix holds the dead key press that must +// be sent before the main key (e.g. ^+a → â). +type HIDCombo struct { + Scancode uint8 `json:"s"` + Modifiers uint8 `json:"m"` + Prefix *HIDCombo `json:"p,omitempty"` +} + +// KeyLegends holds the printable legend for each modifier layer. +// Fields are omitted from JSON when nil (no legend for that layer). +type KeyLegends struct { + Normal *string `json:"normal,omitempty"` + Shift *string `json:"shift,omitempty"` + AltGr *string `json:"altgr,omitempty"` + ShiftAltGr *string `json:"shiftAltgr,omitempty"` + Kana *string `json:"kana,omitempty"` + ShiftKana *string `json:"shiftKana,omitempty"` +} + +// KeyShape is the pre-computed CSS class for the keycap. +type KeyShape string + +const ( + ShapeNormal KeyShape = "" + ShapeISOEnter KeyShape = "iso-enter" + ShapeBigAssEnter KeyShape = "big-ass-enter" + ShapeSteppedCaps KeyShape = "stepped-caps" +) + +// TransportKey is a single fully-resolved keycap. +// JSON field names must match transport/schema.ts TransportKey. +type TransportKey struct { + X float64 `json:"x"` + Y float64 `json:"y"` + W float64 `json:"w"` + H float64 `json:"h"` + W2 float64 `json:"w2,omitempty"` + H2 float64 `json:"h2,omitempty"` + X2 float64 `json:"x2,omitempty"` + Y2 float64 `json:"y2,omitempty"` + + Shape KeyShape `json:"shape"` + Legends KeyLegends `json:"legends"` + Scancode uint8 `json:"scancode"` + DeadLegends []string `json:"deadLegends,omitempty"` + Homing bool `json:"homing"` + Decal bool `json:"decal"` + ControlLike bool `json:"controlLike"` + + Color string `json:"color,omitempty"` + TextColor string `json:"textColor,omitempty"` +} + +// KeyboardLayout is the fully processed layout, stored on disk and served +// over JSON-RPC. Field names must match transport/schema.ts KeyboardLayout. +type KeyboardLayout struct { + ID string `json:"id"` + Name string `json:"name"` + Author string `json:"author,omitempty"` + BoardW float64 `json:"boardW"` + BoardH float64 `json:"boardH"` + Keys []TransportKey `json:"keys"` + CharMap map[string]HIDCombo `json:"charMap"` +} + +// LayoutMeta is the lightweight listing type for the settings dropdown. +type LayoutMeta struct { + ID string `json:"id"` + Name string `json:"name"` + Builtin bool `json:"builtin"` +} + +// LayoutUploadResponse is returned by the HTTP upload endpoint on success. +type LayoutUploadResponse struct { + ID string `json:"id"` + Name string `json:"name"` + KeyCount int `json:"keyCount"` + Warnings []string `json:"warnings,omitempty"` +} + +// --------------------------------------------------------------------------- +// KLE raw types (internal to parser) +// --------------------------------------------------------------------------- + +// kleRawKey is a KLE property object that appears inline in a row. +// All fields are optional; a field's zero value means "not specified". +type kleRawKey struct { + // Per-next-key (reset after each key) + X float64 `json:"x"` + Y float64 `json:"y"` + W float64 `json:"w"` + H float64 `json:"h"` + X2 float64 `json:"x2"` + Y2 float64 `json:"y2"` + W2 float64 `json:"w2"` + H2 float64 `json:"h2"` + Stepped bool `json:"l"` // stepped + Homing bool `json:"n"` // homing + Decal bool `json:"d"` // decal + // Persistent (carried forward until changed) + KeyColor string `json:"c"` // key color + TextColor string `json:"t"` // text color + Alignment int `json:"a"` // alignment (default 4) + FontSize int `json:"f"` // font size + FontSize2 int `json:"f2"` // secondary font size +} + +// kleMetadata is the optional first element of the top-level KLE array. +// Standard KLE fields plus JetKVM extensions (deadKeys, scancodes). +type kleMetadata struct { + Name string `json:"name"` + Author string `json:"author"` + Notes string `json:"notes"` + Backcolor string `json:"backcolor"` + Radii string `json:"radii"` + + // JetKVM extensions (not part of standard KLE format): + + // DeadKeys lists the legend characters that are dead keys on this layout. + // Only keys whose normal legend matches one of these characters get the + // CSS "dead" class. If empty/absent, no keys are marked as dead. + // Example: ["^", "´", "`", "¨", "~"] + DeadKeys []string `json:"deadKeys,omitempty"` + + // Scancodes maps key index (0-based, in parse order) to a USB HID Usage ID, + // overriding the position-based inference. Use for non-standard layouts + // where keys are in unusual positions. + // Example: {"42": 76} — key #42 gets scancode 0x4C (Delete) + Scancodes map[string]uint8 `json:"scancodes,omitempty"` +} + +// deadKeyToCombining maps dead key legend characters to their Unicode +// combining character equivalents. Used for: +// 1. Detecting which keys are dead keys (for the CSS "dead" class) +// 2. Building composed character entries in the charMap (e.g. ^+a → â) +var deadKeyToCombining = map[rune]rune{ + '^': '\u0302', // combining circumflex accent + '~': '\u0303', // combining tilde + '˜': '\u0303', // combining tilde (spacing variant) + '¨': '\u0308', // combining diaeresis + '`': '\u0300', // combining grave accent + '´': '\u0301', // combining acute accent + '¸': '\u0327', // combining cedilla + '˛': '\u0328', // combining ogonek + '˙': '\u0307', // combining dot above + '˚': '\u030A', // combining ring above (spacing modifier) + '°': '\u030A', // combining ring above (degree sign — used on Czech keyboards) + '˝': '\u030B', // combining double acute accent + 'ˇ': '\u030C', // combining caron (háček) + '˘': '\u0306', // combining breve +} + +// ParseKLE parses a KLE JSON byte slice and returns a fully processed +// KeyboardLayout. id and nameOverride allow the caller to set the layout's +// identifier and display name (e.g. from URL params at upload time). +func ParseKLE(rawJSON []byte, id string, nameOverride string) (*KeyboardLayout, error) { + // KLE top level is a JSON array + var topLevel []json.RawMessage + if err := json.Unmarshal(rawJSON, &topLevel); err != nil { + return nil, fmt.Errorf("KLE data must be a JSON array: %w", err) + } + + if len(topLevel) == 0 { + return nil, fmt.Errorf("KLE array is empty") + } + + // Optional first element: metadata object + startIdx := 0 + meta := kleMetadata{} + var firstObj map[string]json.RawMessage + if err := json.Unmarshal(topLevel[0], &firstObj); err == nil { + // It's an object — treat as metadata + _ = json.Unmarshal(topLevel[0], &meta) + startIdx = 1 + } + + // Resolve display name: override > KLE meta > "Unnamed Layout" + name := meta.Name + if nameOverride != "" { + name = nameOverride + } + if name == "" { + name = "Unnamed Layout" + } + name = sanitizeName(name) + author := sanitizeName(meta.Author) + if author == "Unnamed Layout" { + author = "" // don't show default placeholder for author + } + + // Assign ID if not provided + if id == "" { + id = uuid.New().String() + } + + // Build declared dead key set from metadata + declaredDeadKeys := make(map[rune]bool) + for _, ch := range meta.DeadKeys { + if len(ch) > 0 { + r, _ := utf8.DecodeRuneInString(ch) + declaredDeadKeys[r] = true + } + } + + // --- Parse rows --- + var keys []TransportKey + + // Persistent state (carries across rows and keys) + currentColor := "" + currentTextColor := "" + currentY := 0.0 + + for rowIdx := startIdx; rowIdx < len(topLevel); rowIdx++ { + var row []json.RawMessage + if err := json.Unmarshal(topLevel[rowIdx], &row); err != nil { + return nil, fmt.Errorf("KLE row %d is not an array: %w", rowIdx, err) + } + + currentX := 0.0 + + // Per-next-key state + nextX := 0.0 + nextY := 0.0 + nextW := 1.0 + nextH := 1.0 + var nextW2, nextH2, nextX2, nextY2 float64 + hasW2 := false + nextStepped := false + nextHoming := false + nextDecal := false + rowHadYOffset := false + + for _, item := range row { + // Try to unmarshal as object (property modifier) + var props kleRawKey + if err := json.Unmarshal(item, &props); err == nil { + // Check it's actually an object (not a string that happened to unmarshal) + var check map[string]json.RawMessage + if json.Unmarshal(item, &check) == nil { + // Property object + if props.X != 0 { + nextX = props.X + } + if props.Y != 0 { + nextY = props.Y + rowHadYOffset = true + } + if props.W != 0 { + nextW = props.W + } + if props.H != 0 { + nextH = props.H + } + if props.W2 != 0 { + nextW2 = props.W2 + hasW2 = true + } + if props.H2 != 0 { + nextH2 = props.H2 + } + if props.X2 != 0 { + nextX2 = props.X2 + } + if props.Y2 != 0 { + nextY2 = props.Y2 + } + if props.Stepped { + nextStepped = true + } + if props.Homing { + nextHoming = true + } + if props.Decal { + nextDecal = true + } + // Persistent properties (color, font size) are applied immediately + // and affect subsequent keys until overridden again. + // Note: KLE alignment (props.Alignment) is parsed but not consumed — + // we always use the standard KLE legend slot mapping. + if props.KeyColor != "" { + currentColor = props.KeyColor + } + if props.TextColor != "" { + currentTextColor = props.TextColor + } + /* + if props.FontSize != 0 { + currentFontSize = props.FontSize + } + if props.FontSize2 != 0 { + currentFontSize2 = props.FontSize2 + } + */ + continue + } + } + + // Try to unmarshal as string (key legend) + var legendStr string + if err := json.Unmarshal(item, &legendStr); err != nil { + return nil, fmt.Errorf("KLE row %d: item is neither object nor string", rowIdx) + } + + // Apply accumulated offsets + currentX += nextX + if rowHadYOffset { + currentY += nextY + } + + x := currentX + y := currentY + w := nextW + h := nextH + + legends := parseLegends(legendStr) + deadLegends := getDeadKeyInfo(legends, declaredDeadKeys) + shape := detectShape(x, w, h, nextW2, nextH2, nextStepped, hasW2) + + // Scancode is inferred in a post-parse pass after board dimensions are known + + key := TransportKey{ + X: x, + Y: y, + W: w, + H: h, + Shape: shape, + Legends: legends, + DeadLegends: deadLegends, + Homing: nextHoming, + Decal: nextDecal, + } + + if hasW2 { + key.W2 = nextW2 + key.H2 = nextH2 + key.X2 = nextX2 + key.Y2 = nextY2 + } + if currentColor != "" { + key.Color = currentColor + } + if currentTextColor != "" { + key.TextColor = currentTextColor + } + + keys = append(keys, key) + + // Advance x by key width + currentX += nextW + + // Reset per-next-key state + nextX = 0 + nextY = 0 + nextW = 1 + nextH = 1 + nextW2 = 0 + nextH2 = 0 + nextX2 = 0 + nextY2 = 0 + hasW2 = false + nextStepped = false + nextDecal = false + nextHoming = false + rowHadYOffset = false + } + + currentY += 1.0 // advance to next row + } + + if len(keys) == 0 { + return nil, fmt.Errorf("KLE data contains no keys") + } + + // Compute board dimensions + boardW := 0.0 + boardH := 0.0 + for _, k := range keys { + if r := k.X + k.W; r > boardW { + boardW = r + } + if b := k.Y + k.H; b > boardH { + boardH = b + } + } + + // --- Infer scancodes (single pass, after board dimensions are known) --- + table := selectPositionTable(boardW, boardH, len(keys)) + for i := range keys { + k := &keys[i] + if k.Decal { + continue // decals don't send HID events + } + // Check for metadata scancode override first + keyIdxStr := fmt.Sprintf("%d", i) + if override, ok := meta.Scancodes[keyIdxStr]; ok { + k.Scancode = override + continue + } + // Infer from position using the selected table + k.Scancode = inferScancodeWithTable(k.X, k.Y, k.W, k.H, table) + } + + for i := range keys { + keys[i].ControlLike = IsControlScancode(keys[i].Scancode) + } + + normalizeControlLegendsForDisplay(keys) + + layout := &KeyboardLayout{ + ID: id, + Name: name, + Author: author, + BoardW: boardW, + BoardH: boardH, + Keys: keys, + } + + layout.CharMap = buildCharMap(keys) + addDeadKeyCompositions(keys, layout.CharMap, declaredDeadKeys) + + if err := validateLayout(layout); err != nil { + return nil, err + } + + return layout, nil +} + +/** + * Parse a KLE legend string into supported layer slots. + * + * KLE encodes legends as a newline-separated string. Following the standard + * KLE convention (shift-first), JetKVM maps: + * + * index 0 = shift (top-left on keycap) + * index 1 = normal (bottom-left on keycap) + * index 2 = shift+altgr (top-right on keycap) + * index 3 = altgr (bottom-right on keycap) + * + * For Japanese KLE exports with kana legends, JetKVM also maps: + * + * index 8 = kana (kana layer) + * index 10 = shift+kana (shifted kana layer) + * + * Both JetKVM's built-in layouts and community KLE files use this convention. + */ +func parseLegends(legendStr string) KeyLegends { + parts := strings.Split(legendStr, "\n") + get := func(i int) *string { + if i >= len(parts) { + return nil + } + v := parts[i] + if v == "" { + return nil + } + return &v + } + + legends := KeyLegends{ + Normal: get(1), + Shift: get(0), + AltGr: get(3), + ShiftAltGr: get(2), + Kana: get(8), + ShiftKana: get(10), + } + + // Collapse same-on-both-layers shorthand: kbdlayout.info exports keys + // like the numpad as "7\n7", "+\n+", "Space\nSpace" — the same legend on + // both Shift and Normal slots. Reduce to a single Normal legend so the + // downstream auto-case logic can fire (e.g. "Q\nQ" → "Q" → Normal="q", + // Shift="Q") and the keycap renderer doesn't draw the same glyph twice + // in different quadrants. Same treatment for the AltGr and Kana pairs. + collapseEqual := func(primary, shifted **string) { + if *primary != nil && *shifted != nil && **primary == **shifted { + *shifted = nil + } + } + collapseEqual(&legends.Normal, &legends.Shift) + collapseEqual(&legends.AltGr, &legends.ShiftAltGr) + collapseEqual(&legends.Kana, &legends.ShiftKana) + + // Auto-populate shift/normal legends for single-letter keys. + // + // Lowercase normal ("q") → generate shift: "Q" + // Uppercase normal ("Q") with no shift → swap to normal: "q", shift: "Q" + // + // The second case handles standard KLE files that use uppercase letter legends + // (common in externally-authored layouts from keyboard-layout-editor.com). + // + // Safety: we require that lower↔upper round-trips cleanly. This prevents + // collisions with scripts where case mapping is not bijective — notably + // Turkish dotless-ı (U+0131) and dotted-İ (U+0130), where ToUpper('ı')='I' + // but ToLower('I')='i', not 'ı'. Those keys must specify both legends explicitly. + if legends.Normal != nil && legends.Shift == nil { + r, size := utf8.DecodeRuneInString(*legends.Normal) + if size == len(*legends.Normal) && r != utf8.RuneError { + upperR := unicode.ToUpper(r) + lowerR := unicode.ToLower(r) + if upperR != lowerR && + unicode.ToLower(upperR) == lowerR && + unicode.ToUpper(lowerR) == upperR { + upper := string(upperR) + lower := string(lowerR) + if r == lowerR { + // Already lowercase: just add shift + legends.Shift = &upper + } else { + // Uppercase: swap to normal=lower, shift=upper + legends.Normal = &lower + legends.Shift = &upper + } + } + } + } + + // Mirror case: a single-string KLE legend (no '\n' separator) lands in the + // Shift slot because position 0 = top-left in our schema. But a single + // legend conceptually labels the *unmodified* press of the key — "Space" + // means pressing Space (not Shift+Space), "Tab" means pressing Tab. Only + // promote when the entire input was one legend; an explicit two-part + // legend like "Q\n" with an empty normal slot means "shift-only" and + // should be left alone. + if len(parts) == 1 && legends.Normal == nil && legends.Shift != nil { + legend := *legends.Shift + legends.Normal = legends.Shift + legends.Shift = nil + + // For single letters, generate the case pair (Normal=q, Shift=Q). + r, size := utf8.DecodeRuneInString(legend) + if size == len(legend) && r != utf8.RuneError && unicode.IsLetter(r) { + upperR := unicode.ToUpper(r) + lowerR := unicode.ToLower(r) + if upperR != lowerR && + unicode.ToLower(upperR) == lowerR && + unicode.ToUpper(lowerR) == upperR { + lower := string(lowerR) + upper := string(upperR) + legends.Normal = &lower + legends.Shift = &upper + } + } + } + + return legends +} + +const maxNameLength = 100 + +// sanitizeName cleans a user-provided layout name: +// - strips control characters (U+0000–U+001F, U+007F–U+009F) +// - trims whitespace +// - truncates to maxNameLength +func sanitizeName(name string) string { + cleaned := strings.Map(func(r rune) rune { + if r < 0x20 || (r >= 0x7F && r <= 0x9F) { + return -1 // drop control characters + } + return r + }, name) + cleaned = strings.TrimSpace(cleaned) + if utf8.RuneCountInString(cleaned) > maxNameLength { + runes := []rune(cleaned) + cleaned = string(runes[:maxNameLength]) + } + if cleaned == "" { + return "Unnamed Layout" + } + return cleaned +} + +func approxEq(a, b float64) bool { + return math.Abs(a-b) < 0.25 +} + +func detectShape(x, w, h, w2, h2 float64, stepped, hasW2 bool) KeyShape { + if stepped { + return ShapeSteppedCaps + } + // ISO Enter: w=1.25, h=2, with a second rect + if hasW2 && approxEq(h, 2) && approxEq(w, 1.25) && approxEq(w2, 1.5) { + return ShapeISOEnter + } + // Big-ass Enter exists in the main typing area; avoid matching tall numpad keys. + inMainTypingArea := x < 15 + // Big-ass Enter: tall and wider than normal + if inMainTypingArea && approxEq(w, 1.5) && approxEq(h, 2) { + return ShapeBigAssEnter + } + // Big-ass Enter: tall with no second rect + if inMainTypingArea && h > 1.5 && !hasW2 { + return ShapeBigAssEnter + } + return ShapeNormal +} + +// getDeadKeyInfo checks which legend slots contain dead key characters. +// Returns a slice of slot names ("normal", "shift", "altgr", +// "shift-altgr", "kana", "shift-kana") for each slot where the legend +// character is in declaredDeadKeys. +// Returns an empty slice if no dead keys are declared or no legends match. +func getDeadKeyInfo(legends KeyLegends, declaredDeadKeys map[rune]bool) []string { + var deadSlots []string + + if len(declaredDeadKeys) == 0 { + return deadSlots + } + + if legends.Normal != nil { + r, _ := utf8.DecodeRuneInString(*legends.Normal) + if declaredDeadKeys[r] { + deadSlots = append(deadSlots, "normal") + } + } + if legends.Shift != nil { + r, _ := utf8.DecodeRuneInString(*legends.Shift) + if declaredDeadKeys[r] { + deadSlots = append(deadSlots, "shift") + } + } + if legends.AltGr != nil { + r, _ := utf8.DecodeRuneInString(*legends.AltGr) + if declaredDeadKeys[r] { + deadSlots = append(deadSlots, "altgr") + } + } + if legends.ShiftAltGr != nil { + r, _ := utf8.DecodeRuneInString(*legends.ShiftAltGr) + if declaredDeadKeys[r] { + deadSlots = append(deadSlots, "shift-altgr") + } + } + if legends.Kana != nil { + r, _ := utf8.DecodeRuneInString(*legends.Kana) + if declaredDeadKeys[r] { + deadSlots = append(deadSlots, "kana") + } + } + if legends.ShiftKana != nil { + r, _ := utf8.DecodeRuneInString(*legends.ShiftKana) + if declaredDeadKeys[r] { + deadSlots = append(deadSlots, "shift-kana") + } + } + + return deadSlots +} + +func buildCharMap(keys []TransportKey) map[string]HIDCombo { + m := make(map[string]HIDCombo) + + // Sort a copy by position for deterministic first-occurrence behaviour. + // We must not mutate the original slice — it preserves KLE parse order, + // which the scancodes metadata override uses. + sorted := make([]TransportKey, len(keys)) + copy(sorted, keys) + slices.SortStableFunc(sorted, func(a, b TransportKey) int { + if comp := cmp.Compare(a.Y, b.Y); comp != 0 { + return comp + } + return cmp.Compare(a.X, b.X) + }) + + for _, key := range sorted { + if key.Scancode == 0 { + continue // non-typeable keys and decals don't send HID events + } + + addChar(m, key.Legends.Normal, key.Scancode, ModNone) + addChar(m, key.Legends.Shift, key.Scancode, ModLShift) + addChar(m, key.Legends.AltGr, key.Scancode, ModAltGr) + addChar(m, key.Legends.ShiftAltGr, key.Scancode, ModShiftAltGr) + } + + addPasteableControlChars(keys, m) + + return m +} + +// addPasteableControlChars maps the small set of control characters that +// commonly appear in pasted text — newline (\n), carriage return (\r), +// CRLF (\r\n), and tab (\t) — to the corresponding HID scancode. Without +// these the paste path would silently drop them: PasteModal looks up each +// segmented input character verbatim in charMap, but addChar/Intl.Segmenter +// never produce entries for runes < U+0020. +// +// Layouts that lack an Enter or Tab key (decals only, hypothetically) get +// no entry — paste of those control chars will continue to be skipped. +func addPasteableControlChars(keys []TransportKey, m map[string]HIDCombo) { + hasScancode := func(target uint8) bool { + for _, k := range keys { + if k.Scancode == target { + return true + } + } + return false + } + if hasScancode(hidEnter) { + enter := HIDCombo{Scancode: hidEnter, Modifiers: ModNone} + // Intl.Segmenter on the frontend treats CRLF as a single grapheme + // cluster, so map all three forms. + m["\n"] = enter + m["\r"] = enter + m["\r\n"] = enter + } + if hasScancode(hidTab) { + m["\t"] = HIDCombo{Scancode: hidTab, Modifiers: ModNone} + } +} + +func addChar(m map[string]HIDCombo, legend *string, scancode, mods uint8) { + if legend == nil || *legend == "" { + return + } + if !ScancodeProducesText(scancode) { + return + } + + // Space's legend is normalized to "Space" by normalizeControlLegendsForDisplay + // for the UI, and user-uploaded layouts may use "␠", " ", or "Space". The + // pasted character that should map to this scancode is always U+0020. + char := *legend + if scancode == hidSpace { + char = " " + } + + if utf8.RuneCountInString(char) != 1 { + // Only single Unicode codepoints; skip named keys like "Enter" + return + } + r, _ := utf8.DecodeRuneInString(char) + if r < 0x20 { + // skip control characters + return + } + if _, exists := m[char]; !exists { + m[char] = HIDCombo{Scancode: scancode, Modifiers: mods} + } +} + +// normalizeControlLegendsForDisplay rewrites alternate forms of special-key +// legends to their canonical display form, using the taxonomy in +// keyaliases.json. For example, "Backspace" / "⟵" / "␈" / "BS" all become "⌫". +// +// This is intentionally limited to non-text keys plus Space so printable +// legends and typing behavior remain unchanged. +func normalizeControlLegendsForDisplay(keys []TransportKey) { + normalize := func(legend **string) { + if *legend == nil { + return + } + canonical, ok := controlLegendDisplayMap[**legend] + if !ok || canonical == **legend { + return + } + v := canonical + *legend = &v + } + + for i := range keys { + k := &keys[i] + if k.Scancode == 0 { + continue + } + if ScancodeProducesText(k.Scancode) && k.Scancode != hidSpace { + continue + } + normalize(&k.Legends.Normal) + normalize(&k.Legends.Shift) + normalize(&k.Legends.AltGr) + normalize(&k.Legends.ShiftAltGr) + normalize(&k.Legends.Kana) + normalize(&k.Legends.ShiftKana) + } +} + +// addDeadKeyCompositions enriches the charMap with composed characters +// produced by dead key + base key sequences. +// +// Only keys whose legend character appears in declaredDeadKeys are treated as +// dead keys. This prevents layouts without dead keys (e.g. en-US) from +// incorrectly generating compositions — on those layouts, characters like ^ +// and ~ produce output directly and do not enter a dead key state. +// +// For each dead key, the function finds all base characters in the charMap and +// checks whether combining the dead key's Unicode combining character with the +// base produces a composed form (via NFC normalization). If so, the composed +// character is added to charMap with a Prefix pointing to the dead key's HIDCombo. +// +// Standalone dead key characters (e.g. typing just `^`) are also updated to +// require a Space follow-up, matching how real keyboards emit dead key characters. +func addDeadKeyCompositions(keys []TransportKey, charMap map[string]HIDCombo, declaredDeadKeys map[rune]bool) { + if len(declaredDeadKeys) == 0 { + return + } + + type deadKeyInfo struct { + combo HIDCombo // scancode + modifiers to press the dead key + combining rune // Unicode combining character + displayKey rune // the legend character as it appears on the keycap + layerIdx int // 0..3 — used to break ties at equal modifier complexity + } + + // Collect dead key legends that are both declared AND have a known + // combining character mapping. When the same dead-key character appears on + // multiple layers (e.g. ´ on both AltGr and ShiftAltGr in fr_BE) or on + // multiple physical keys, we keep only the entry with the simplest + // modifier — otherwise the composition loop and the standalone replacement + // below could end up using different combos for the same character, + // producing inconsistent dead-key-then-base sequences. + chosen := map[rune]deadKeyInfo{} + for _, key := range keys { + if key.Scancode == 0 { + continue + } + layers := []struct { + legend *string + mods uint8 + }{ + {key.Legends.Normal, ModNone}, + {key.Legends.Shift, ModLShift}, + {key.Legends.AltGr, ModAltGr}, + {key.Legends.ShiftAltGr, ModShiftAltGr}, + } + for layerIdx, layer := range layers { + if layer.legend == nil { + continue + } + r, _ := utf8.DecodeRuneInString(*layer.legend) + if !declaredDeadKeys[r] { + continue + } + combining, ok := deadKeyToCombining[r] + if !ok { + continue + } + candidate := deadKeyInfo{ + combo: HIDCombo{Scancode: key.Scancode, Modifiers: layer.mods}, + combining: combining, + displayKey: r, + layerIdx: layerIdx, + } + existing, seen := chosen[r] + // Prefer fewer modifier bits; tie-break with the layer enum order + // (Normal < Shift < AltGr < ShiftAltGr) so the result is deterministic. + if !seen || + bits.OnesCount8(candidate.combo.Modifiers) < bits.OnesCount8(existing.combo.Modifiers) || + (bits.OnesCount8(candidate.combo.Modifiers) == bits.OnesCount8(existing.combo.Modifiers) && + candidate.layerIdx < existing.layerIdx) { + chosen[r] = candidate + } + } + } + + if len(chosen) == 0 { + return + } + + // Materialise in displayKey order so charMap construction is deterministic + // even when callers iterate it (or compare two parses for equality). + deadKeys := make([]deadKeyInfo, 0, len(chosen)) + for _, dk := range chosen { + deadKeys = append(deadKeys, dk) + } + slices.SortFunc(deadKeys, func(a, b deadKeyInfo) int { + return cmp.Compare(a.displayKey, b.displayKey) + }) + + // Snapshot base chars to iterate (we'll be adding to charMap) + type baseEntry struct { + char string + r rune + combo HIDCombo + } + var bases []baseEntry + for ch, combo := range charMap { + r, _ := utf8.DecodeRuneInString(ch) + if r >= 0x20 && combo.Prefix == nil { + bases = append(bases, baseEntry{ch, r, combo}) + } + } + + for _, dk := range deadKeys { + prefix := dk.combo // copy for pointer stability + + for _, base := range bases { + // Try NFC composition: base + combining → composed + candidate := string([]rune{base.r, dk.combining}) + composed := norm.NFC.String(candidate) + + // If NFC produced a different single character, it's a valid composition + if composed == candidate { + continue + } + composedRune, _ := utf8.DecodeRuneInString(composed) + if composedRune == utf8.RuneError || utf8.RuneCountInString(composed) != 1 { + continue + } + + composedStr := composed + if _, exists := charMap[composedStr]; !exists { + charMap[composedStr] = HIDCombo{ + Scancode: base.combo.Scancode, + Modifiers: base.combo.Modifiers, + Prefix: &prefix, + } + } + } + + // Standalone dead key: dead key + Space → the dead key character itself. + // Use the display rune captured from the actual key legend, not a + // reverse lookup from deadKeyToCombining (which has duplicate values + // and non-deterministic map iteration order). + // + // The Prefix==nil guard is defensive: deadKeys is already deduped to + // one entry per displayKey above, so we shouldn't see this entry's + // char already prefixed by an earlier loop iteration. The check still + // prevents accidental rewrites if a future caller pre-populates + // charMap with prefixed entries before invoking this function. + deadChar := string(dk.displayKey) + if existing, exists := charMap[deadChar]; exists && existing.Prefix == nil { + charMap[deadChar] = HIDCombo{ + Scancode: hidSpace, + Modifiers: ModNone, + Prefix: &prefix, + } + } + } +} + +func validateLayout(layout *KeyboardLayout) error { + if len(layout.Keys) < 10 { + return fmt.Errorf("layout only has %d keys — does not look like a full keyboard", len(layout.Keys)) + } + if layout.BoardW < 5 || layout.BoardW > 30 { + return fmt.Errorf("unusual board width %.2f units (expected 5–30)", layout.BoardW) + } + if layout.BoardH < 2 || layout.BoardH > 10 { + return fmt.Errorf("unusual board height %.2f units (expected 2–10)", layout.BoardH) + } + recognised, total := 0, 0 + for _, k := range layout.Keys { + if k.Decal { + continue // decorative labels don't have scancodes by design + } + total++ + if k.Scancode != 0 { + recognised++ + } + } + if total == 0 { + return fmt.Errorf("layout has no non-decal keys") + } + pct := float64(recognised) / float64(total) * 100 + if pct < 50 { + return fmt.Errorf("only %.0f%% of keys mapped to HID scancodes — layout may be non-standard", pct) + } + return nil +} + +// collectLayoutWarnings returns non-fatal issues found in a parsed layout. +// These don't prevent the layout from being used but may indicate problems +// the user should know about (e.g. unmapped keys, missing charMap coverage). +func collectLayoutWarnings(layout *KeyboardLayout) []string { + var warnings []string + + // Check scancode coverage + unmapped := 0 + total := 0 + for _, k := range layout.Keys { + if k.Decal { + continue + } + total++ + if k.Scancode == 0 { + unmapped++ + } + } + if unmapped > 0 { + pct := float64(total-unmapped) / float64(total) * 100 + warnings = append(warnings, + fmt.Sprintf("%d of %d keys have no HID scancode (%.0f%% coverage). "+ + "These keys will not send input. Consider adding scancode overrides "+ + "in the KLE metadata.", unmapped, total, pct)) + } + + // Check charMap coverage — are common characters reachable? + if len(layout.CharMap) == 0 { + warnings = append(warnings, "No characters mapped — paste text will not work with this layout.") + } else if len(layout.CharMap) < 30 { + warnings = append(warnings, + fmt.Sprintf("Only %d characters mapped (expected 50+). "+ + "Paste text may not work for some characters.", len(layout.CharMap))) + } + + // Check for keys with legends but no scancode (likely position inference failures) + legendless := 0 + for _, k := range layout.Keys { + if k.Decal || k.Scancode != 0 { + continue + } + if k.Legends.Normal != nil || k.Legends.Shift != nil { + legendless++ + } + } + if legendless > 5 { + warnings = append(warnings, + fmt.Sprintf("%d keys have legends but no scancode — the layout's form factor "+ + "may not be fully supported. 75%% and larger keyboards work best.", legendless)) + } + + return warnings +} diff --git a/internal/keyboard/keyboard_test.go b/internal/keyboard/keyboard_test.go new file mode 100644 index 000000000..a475a6e75 --- /dev/null +++ b/internal/keyboard/keyboard_test.go @@ -0,0 +1,1957 @@ +// internal/keyboard/keyboard_test.go +// +// Table-driven tests for the KLE parser. +// +// Run with: go test ./internal/keyboard/... + +package keyboard + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "testing" + "unicode/utf8" +) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func mustParse(t *testing.T, kleJSON string) *KeyboardLayout { + t.Helper() + layout, err := ParseKLE([]byte(kleJSON), "test", "Test Layout") + if err != nil { + t.Fatalf("ParseKLE failed: %v", err) + } + return layout +} + +func findKey(layout *KeyboardLayout, x, y float64) *TransportKey { + for i := range layout.Keys { + k := &layout.Keys[i] + if approxEq(k.X, x) && approxEq(k.Y, y) { + return k + } + } + return nil +} + +func str(s string) *string { return &s } + +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} + +// --------------------------------------------------------------------------- +// Minimal valid layout +// --------------------------------------------------------------------------- + +const minimalKLE = `[ + {"name": "Test Layout", "author": "Test"}, + ["Esc","F1","F2","F3","F4","F5","F6","F7","F8","F9","F10","F11","F12"], + [{"w":1.5},"Tab","Q","W","E","R","T","Y","U","I","O","P","[","]"], + [{"w":1.75},"Caps","A","S","D","F","G","H","J","K","L",";","'",{"w":2.25},"Enter"], + [{"w":2.25},"Shift","Z","X","C","V","B","N","M",",",".","/",{"w":2.75},"Shift"], + [{"w":1.25},"Ctrl",{"w":1.25},"Win",{"w":1.25},"Alt",{"w":6.25},"Space",{"w":1.25},"Alt",{"w":1.25},"Win",{"w":1.25},"Menu",{"w":1.25},"Ctrl"] +]` + +func TestParsesMinimalLayout(t *testing.T) { + layout := mustParse(t, minimalKLE) + + if layout.Name != "Test Layout" { + t.Errorf("expected name 'Test Layout', got %q", layout.Name) + } + if layout.Author != "Test" { + t.Errorf("expected author 'Test', got %q", layout.Author) + } + if len(layout.Keys) == 0 { + t.Fatal("expected keys, got none") + } + if layout.BoardW <= 0 { + t.Errorf("expected positive boardW, got %v", layout.BoardW) + } +} + +// --------------------------------------------------------------------------- +// Legend parsing +// --------------------------------------------------------------------------- + +func TestLegendLayers(t *testing.T) { + // Standard KLE: "!\n1\n¹\n²" → shift=!, normal=1, shiftAltgr=¹, altgr=² + legends := parseLegends("!\n1\n¹\n²") + + if legends.Normal == nil || *legends.Normal != "1" { + t.Errorf("normal: expected '1', got %v", legends.Normal) + } + if legends.Shift == nil || *legends.Shift != "!" { + t.Errorf("shift: expected '!', got %v", legends.Shift) + } + if legends.AltGr == nil || *legends.AltGr != "²" { + t.Errorf("altgr: expected '²', got %v", legends.AltGr) + } + if legends.ShiftAltGr == nil || *legends.ShiftAltGr != "¹" { + t.Errorf("shiftAltgr: expected '¹', got %v", legends.ShiftAltGr) + } +} + +func TestLegendKanaSlots(t *testing.T) { + // Japanese KLE export style with kana in slot 8 and shifted kana in slot 10. + legends := parseLegends("A\na\n\n\n\n\n\n\nア\n\nァ") + + if legends.Normal == nil || *legends.Normal != "a" { + t.Errorf("normal: expected 'a', got %v", legends.Normal) + } + if legends.Shift == nil || *legends.Shift != "A" { + t.Errorf("shift: expected 'A', got %v", legends.Shift) + } + if legends.Kana == nil || *legends.Kana != "ア" { + t.Errorf("kana: expected 'ア', got %v", legends.Kana) + } + if legends.ShiftKana == nil || *legends.ShiftKana != "ァ" { + t.Errorf("shiftKana: expected 'ァ', got %v", legends.ShiftKana) + } +} + +func TestLegendEmptySlots(t *testing.T) { + // Standard KLE: "Q\nq" → shift=Q, normal=q, altgr=nil, shiftAltgr=nil + legends := parseLegends("Q\nq") + + if legends.Normal == nil || *legends.Normal != "q" { + t.Errorf("normal: expected 'q'") + } + if legends.Shift == nil || *legends.Shift != "Q" { + t.Errorf("shift: expected 'Q'") + } + if legends.AltGr != nil { + t.Errorf("altgr: expected nil, got %v", *legends.AltGr) + } + if legends.ShiftAltGr != nil { + t.Errorf("shiftAltgr: expected nil, got %v", *legends.ShiftAltGr) + } +} + +func TestLegendSingleChar(t *testing.T) { + // Single uppercase letter in shift slot (standard KLE: "A" = shift only). + // Mirror-case moves to Normal and auto-cases → normal="a", shift="A" + legends := parseLegends("A") + if legends.Normal == nil || *legends.Normal != "a" { + t.Errorf("expected normal='a', got %v", legends.Normal) + } + if legends.Shift == nil || *legends.Shift != "A" { + t.Errorf("expected shift='A', got %v", legends.Shift) + } +} + +func TestLegendAutoUppercase(t *testing.T) { + // Single lowercase letter: mirror-case from shift slot → normal=q, shift=Q + legends := parseLegends("q") + if legends.Normal == nil || *legends.Normal != "q" { + t.Errorf("expected normal='q', got %v", legends.Normal) + } + if legends.Shift == nil || *legends.Shift != "Q" { + t.Errorf("expected auto shift='Q', got %v", legends.Shift) + } + + // Accented letters should also work + legends2 := parseLegends("ö") + if legends2.Normal == nil || *legends2.Normal != "ö" { + t.Errorf("expected normal='ö', got %v", legends2.Normal) + } + if legends2.Shift == nil || *legends2.Shift != "Ö" { + t.Errorf("expected auto shift='Ö', got %v", legends2.Shift) + } + + // Cyrillic should work + legends3 := parseLegends("й") + if legends3.Shift == nil || *legends3.Shift != "Й" { + t.Errorf("expected auto shift='Й', got %v", legends3.Shift) + } + + // Uppercase single letter — mirror-case + auto-lowercase → normal="q", shift="Q" + legends4 := parseLegends("Q") + if legends4.Normal == nil || *legends4.Normal != "q" { + t.Errorf("expected auto normal='q', got %v", legends4.Normal) + } + if legends4.Shift == nil || *legends4.Shift != "Q" { + t.Errorf("expected auto shift='Q', got %v", legends4.Shift) + } + + // Multi-char single legend "Tab" / "Space" / "Esc": named keys label the + // unmodified press, so the mirror-case promotes them to the Normal slot. + // Without this, addChar (for Space, which is text-producing) would attach + // the LShift modifier to charMap[" "]. + legends5 := parseLegends("Tab") + if legends5.Normal == nil || *legends5.Normal != "Tab" { + t.Errorf("expected normal='Tab', got %v", legends5.Normal) + } + if legends5.Shift != nil { + t.Errorf("expected shift=nil for 'Tab', got %q", *legends5.Shift) + } + + legendsSpace := parseLegends("Space") + if legendsSpace.Normal == nil || *legendsSpace.Normal != "Space" { + t.Errorf("expected normal='Space', got %v", legendsSpace.Normal) + } + if legendsSpace.Shift != nil { + t.Errorf("expected shift=nil for 'Space', got %q", *legendsSpace.Shift) + } + + // Explicit shift-only "Q\n" (two parts, second empty) must still go in Shift. + legendsShiftOnly := parseLegends("Q\n") + if legendsShiftOnly.Normal != nil { + t.Errorf("expected normal=nil for 'Q\\n', got %q", *legendsShiftOnly.Normal) + } + if legendsShiftOnly.Shift == nil || *legendsShiftOnly.Shift != "Q" { + t.Errorf("expected shift='Q' for 'Q\\n', got %v", legendsShiftOnly.Shift) + } + + // "X\nX" shorthand from kbdlayout.info exports collapses to a single + // Normal legend so it's morally equivalent to "X". Numpad digits and + // symbols arrive in this form when re-exporting external KLE data. + for _, tc := range []struct { + input string + wantNormal string + wantShift string // "" means nil; non-empty means Shift should be set (auto-cased letters) + wantShiftNil bool + }{ + {input: "7\n7", wantNormal: "7", wantShiftNil: true}, + {input: "+\n+", wantNormal: "+", wantShiftNil: true}, + {input: "Space\nSpace", wantNormal: "Space", wantShiftNil: true}, + // Letter X\nX is also collapsed, then auto-cased into a proper pair. + {input: "Q\nQ", wantNormal: "q", wantShift: "Q"}, + {input: "q\nq", wantNormal: "q", wantShift: "Q"}, + } { + got := parseLegends(tc.input) + if got.Normal == nil || *got.Normal != tc.wantNormal { + t.Errorf("parseLegends(%q): Normal = %v, want %q", tc.input, got.Normal, tc.wantNormal) + } + if tc.wantShiftNil { + if got.Shift != nil { + t.Errorf("parseLegends(%q): Shift = %q, want nil", tc.input, *got.Shift) + } + } else { + if got.Shift == nil || *got.Shift != tc.wantShift { + t.Errorf("parseLegends(%q): Shift = %v, want %q", tc.input, got.Shift, tc.wantShift) + } + } + } + + // Explicit two-part legend should be respected: "!\n1" → shift="!", normal="1" + legends6 := parseLegends("!\n1") + if legends6.Normal == nil || *legends6.Normal != "1" { + t.Errorf("expected normal='1', got %v", legends6.Normal) + } + if legends6.Shift == nil || *legends6.Shift != "!" { + t.Errorf("expected shift='!', got %v", legends6.Shift) + } + + // Turkish dotless-ı (U+0131): mirror-case moves to Normal, but round-trip + // fails (ToUpper('ı')='I', ToLower('I')='i' ≠ 'ı'), so no auto-case. + legends7 := parseLegends("ı") + if legends7.Normal == nil || *legends7.Normal != "ı" { + t.Errorf("Turkish ı: expected normal='ı', got %v", legends7.Normal) + } + if legends7.Shift != nil { + t.Errorf("Turkish ı should not auto-uppercase (round-trip unsafe), got shift=%q", *legends7.Shift) + } + + // Turkish İ (U+0130): mirror-case moves to Normal, round-trip fails. + legends8 := parseLegends("İ") + if legends8.Normal == nil || *legends8.Normal != "İ" { + t.Errorf("Turkish İ: expected normal='İ', got %v", legends8.Normal) + } + if legends8.Shift != nil { + t.Errorf("Turkish İ should not auto-lowercase (round-trip unsafe), got shift=%q", *legends8.Shift) + } +} + +func TestNormalizeControlLegendsForDisplay(t *testing.T) { + keys := []TransportKey{ + {Scancode: hidEscape, Legends: KeyLegends{Normal: str("␛"), Shift: str("␛")}}, + {Scancode: hidTab, Legends: KeyLegends{Normal: str("␉")}}, + {Scancode: hidBackspace, Legends: KeyLegends{Normal: str("␈")}}, + {Scancode: hidEnter, Legends: KeyLegends{Normal: str("␍")}}, + {Scancode: hidSpace, Legends: KeyLegends{Normal: str("␠")}}, + // Printable key should remain untouched. + {Scancode: hidA, Legends: KeyLegends{Normal: str("a"), Shift: str("A")}}, + } + + normalizeControlLegendsForDisplay(keys) + + if keys[0].Legends.Normal == nil || *keys[0].Legends.Normal != "Esc" { + t.Fatalf("escape normal legend: expected Esc, got %v", keys[0].Legends.Normal) + } + if keys[1].Legends.Normal == nil || *keys[1].Legends.Normal != "⭾" { + t.Fatalf("tab normal legend: expected ⇥, got %v", keys[1].Legends.Normal) + } + if keys[2].Legends.Normal == nil || *keys[2].Legends.Normal != "⌫" { + t.Fatalf("backspace normal legend: expected ⌫, got %v", keys[2].Legends.Normal) + } + if keys[3].Legends.Normal == nil || *keys[3].Legends.Normal != "⏎" { + t.Fatalf("enter normal legend: expected ⏎, got %v", keys[3].Legends.Normal) + } + if keys[4].Legends.Normal == nil || *keys[4].Legends.Normal != "Space" { + t.Fatalf("space normal legend: expected Space, got %v", keys[4].Legends.Normal) + } + if keys[5].Legends.Normal == nil || *keys[5].Legends.Normal != "a" { + t.Fatalf("printable key should stay unchanged, got %v", keys[5].Legends.Normal) + } +} + +// --------------------------------------------------------------------------- +// Shape detection +// --------------------------------------------------------------------------- + +func TestShapeNormal(t *testing.T) { + if s := detectShape(0, 1, 1, 0, 0, false, false); s != ShapeNormal { + t.Errorf("1×1 key: expected ShapeNormal, got %q", s) + } +} + +func TestShapeISOEnter(t *testing.T) { + if s := detectShape(13, 1.25, 2, 1.5, 1, false, true); s != ShapeISOEnter { + t.Errorf("ISO Enter: expected ShapeISOEnter, got %q", s) + } +} + +func TestShapeSteppedCaps(t *testing.T) { + if s := detectShape(0, 1.75, 1, 0, 0, true, false); s != ShapeSteppedCaps { + t.Errorf("stepped: expected ShapeSteppedCaps, got %q", s) + } +} + +func TestShapeBigAssEnter(t *testing.T) { + // Exact 1.5×2 match + if s := detectShape(13, 1.5, 2, 0, 0, false, false); s != ShapeBigAssEnter { + t.Errorf("1.5×2 big-ass enter: expected ShapeBigAssEnter, got %q", s) + } + // Alternate tall key (h > 1.5, no w2) — second code path + if s := detectShape(13, 1.0, 2, 0, 0, false, false); s != ShapeBigAssEnter { + t.Errorf("1×2 tall key: expected ShapeBigAssEnter, got %q", s) + } +} + +func TestShapeNumpadTallKeysNotBigAssEnter(t *testing.T) { + if s := detectShape(17.5, 1.0, 2, 0, 0, false, false); s != ShapeNormal { + t.Errorf("numpad 1×2 key: expected ShapeNormal, got %q", s) + } + if s := detectShape(17.5, 1.5, 2, 0, 0, false, false); s != ShapeNormal { + t.Errorf("numpad 1.5×2 key: expected ShapeNormal, got %q", s) + } +} + +// --------------------------------------------------------------------------- +// Scancode inference — full-size table +// --------------------------------------------------------------------------- + +var fullSizeScancodeTests = []struct { + name string + x, y float64 + w, h float64 + expected uint8 +}{ + // Real KLE positions: func row y=0, then y:0.5 gap, + // so number row y=1.5, qwerty y=2.5, home y=3.5, shift y=4.5, mods y=5.5 + {"Escape", 0, 0, 1, 1, hidEscape}, + {"F1", 2, 0, 1, 1, hidF1}, + {"F4", 5, 0, 1, 1, hidF4}, + {"F5", 6.5, 0, 1, 1, hidF5}, + {"F12", 14, 0, 1, 1, hidF12}, + {"PrintScreen", 15.25, 0, 1, 1, hidPrintScreen}, + {"1 (number)", 1, 1.5, 1, 1, hidN1}, + {"0 (number)", 10, 1.5, 1, 1, hidN0}, + {"Backspace", 13, 1.5, 2, 1, hidBackspace}, + {"Insert", 15.25, 1.5, 1, 1, hidInsert}, + {"NumLock", 18.5, 1.5, 1, 1, hidNumLock}, + {"Q", 1.5, 2.5, 1, 1, hidQ}, + {"W", 2.5, 2.5, 1, 1, hidW}, + {"P", 10.5, 2.5, 1, 1, hidP}, + {"Backslash", 13.5, 2.5, 1.5, 1, hidBackslash}, + {"Delete", 15.25, 2.5, 1, 1, hidDelete}, + {"KP7", 18.5, 2.5, 1, 1, hidKP7}, + {"A", 1.75, 3.5, 1, 1, hidA}, + {"CapsLock", 0, 3.5, 1.75, 1, hidCapsLock}, + {"Enter (ANSI)", 12.75, 3.5, 2.25, 1, hidEnter}, + {"Hash (ISO)", 12.75, 3.5, 1, 1, hidHashTilde}, + {"KP5", 19.5, 3.5, 1, 1, hidKP5}, + {"LShift", 0, 4.5, 2.25, 1, hidLShift}, + {"ISO extra key", 1.25, 4.5, 1, 1, hidISOKey}, + {"Z (ANSI)", 2.25, 4.5, 1, 1, hidZ}, + {"RShift", 12.25, 4.5, 2.75, 1, hidRShift}, + {"ArrowUp", 16.25, 4.5, 1, 1, hidArrowUp}, + {"KPEnter", 21.5, 4.5, 1, 2, hidKPEnter}, + {"Space", 3.75, 5.5, 6.25, 1, hidSpace}, + {"LCtrl", 0, 5.5, 1.25, 1, hidLCtrl}, + {"RAlt", 10, 5.5, 1.25, 1, hidRAlt}, + {"ArrowLeft", 15.25, 5.5, 1, 1, hidArrowLeft}, + {"ArrowDown", 16.25, 5.5, 1, 1, hidArrowDown}, + {"ArrowRight", 17.25, 5.5, 1, 1, hidArrowRight}, + {"KP0", 18.5, 5.5, 2, 1, hidKP0}, + // Special shapes + {"ISO Enter", 13.5, 2.5, 1.25, 2, hidEnter}, + {"Numpad +", 21.5, 2.5, 1, 2, hidKPPlus}, +} + +func TestScancodeInferenceFullSize(t *testing.T) { + for _, tt := range fullSizeScancodeTests { + t.Run(tt.name, func(t *testing.T) { + got := inferScancodeWithTable(tt.x, tt.y, tt.w, tt.h, fullSizeTable) + if got != tt.expected { + t.Errorf("inferScancodeWithTable(%.2f, %.2f, w=%.2f, h=%.2f, fullSize): expected 0x%02X, got 0x%02X", + tt.x, tt.y, tt.w, tt.h, tt.expected, got) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Scancode inference — compact table +// --------------------------------------------------------------------------- + +var compactScancodeTests = []struct { + name string + x, y float64 + w, h float64 + expected uint8 +}{ + // Compact: no y:0.5 gap, rows at y=0,1,2,3,4,5 + {"Escape", 0, 0, 1, 1, hidEscape}, + {"F1", 1, 0, 1, 1, hidF1}, + {"F12", 12, 0, 1, 1, hidF12}, + {"Grave", 0, 1, 1, 1, hidGrave}, + {"1 (number)", 1, 1, 1, 1, hidN1}, + {"Backspace", 13, 1, 2, 1, hidBackspace}, + {"Tab", 0, 2, 1.5, 1, hidTab}, + {"Q", 1.5, 2, 1, 1, hidQ}, + {"Backslash", 13.5, 2, 1.5, 1, hidBackslash}, + {"CapsLock", 0, 3, 1.75, 1, hidCapsLock}, + {"A", 1.75, 3, 1, 1, hidA}, + {"Enter (compact)", 12.75, 3, 2.25, 1, hidEnter}, + {"Hash (ISO compact)", 12.75, 3, 1, 1, hidHashTilde}, // narrow key at same x = hash, not enter + {"LShift", 0, 4, 2.25, 1, hidLShift}, + {"Z", 2.25, 4, 1, 1, hidZ}, + {"RShift", 12.25, 4, 2.75, 1, hidRShift}, + {"Space", 3.75, 5, 6.25, 1, hidSpace}, + {"LCtrl", 0, 5, 1.25, 1, hidLCtrl}, + {"RAlt", 10, 5, 1, 1, hidRAlt}, + // ISO Enter still detected by h >= 2 rule + {"ISO Enter (compact)", 13.5, 2, 1.25, 2, hidEnter}, + // 75% keyboard arrow key positioning variance (0.25u off from table) + // Reference table: {14.25, hidArrowUp} but 1.75u RShift (start 12.25, end 14.0) places arrow at 14.0 + {"75%: ArrowUp (shifted left)", 14.0, 4, 1, 1, hidArrowUp}, + {"75%: ArrowDown (shifted left)", 14.0, 5, 1, 1, hidArrowDown}, +} + +func TestScancodeInferenceCompact(t *testing.T) { + for _, tt := range compactScancodeTests { + t.Run(tt.name, func(t *testing.T) { + got := inferScancodeWithTable(tt.x, tt.y, tt.w, tt.h, compactTable) + if got != tt.expected { + t.Errorf("inferScancodeWithTable(%.2f, %.2f, w=%.2f, h=%.2f, compact): expected 0x%02X, got 0x%02X", + tt.x, tt.y, tt.w, tt.h, tt.expected, got) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Position table sort-order invariant +// --------------------------------------------------------------------------- + +func TestPositionTablesSorted(t *testing.T) { + // inferScancodeWithTable relies on posEntry slices being sorted by xStart + // for its early-break optimization. A misordered entry would silently + // produce wrong scancodes with no error. + tables := map[string]map[int][]posEntry{ + "fullSize": fullSizeTable, + "compact": compactTable, + } + for name, table := range tables { + for rowIdx, row := range table { + for i := 1; i < len(row); i++ { + if row[i].xStart < row[i-1].xStart { + t.Errorf("%s table row %d: entries out of order at index %d "+ + "(%.2f < %.2f)", name, rowIdx, i, + row[i].xStart, row[i-1].xStart) + } + } + } + } +} + +// --------------------------------------------------------------------------- +// Table selection +// --------------------------------------------------------------------------- + +func TestSelectPositionTable(t *testing.T) { + tests := []struct { + name string + boardW float64 + boardH float64 + keyCount int + wantFull bool + }{ + {"ANSI 104 (wide + many keys)", 22.5, 6.5, 104, true}, + {"ISO 105 (wide + many keys)", 22.5, 6.5, 105, true}, + {"JIS 109 (wide + many keys)", 22.5, 6.5, 109, true}, + {"wide board, few keys", 21, 5, 80, true}, + {"narrow board, 100+ keys", 18, 6.5, 100, true}, + {"TKL (narrow, 87 keys)", 18, 6.5, 87, false}, + {"75% (narrow, ~84 keys)", 16, 6, 84, false}, + {"65% (narrow, ~68 keys, fallback)", 16, 5, 68, true}, + {"60% (narrow, ~61 keys, fallback)", 15, 5, 61, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := selectPositionTable(tt.boardW, tt.boardH, tt.keyCount) + // Compare by identity: fullSizeTable has a numpad row (row 2 has KP7 at x=18.5), + // while compactTable does not. Check whether row 2 has entries past x=18. + hasNumpad := false + for _, entry := range got[2] { + if entry.xStart >= 18 { + hasNumpad = true + break + } + } + if hasNumpad != tt.wantFull { + which := "compact" + if hasNumpad { + which = "full-size" + } + t.Errorf("selectPositionTable(%.1f, %.1f, %d) returned %s table", + tt.boardW, tt.boardH, tt.keyCount, which) + } + }) + } +} + +// --------------------------------------------------------------------------- +// sanitizeName +// --------------------------------------------------------------------------- + +func TestSanitizeNamePassthrough(t *testing.T) { + if got := sanitizeName("My Layout"); got != "My Layout" { + t.Errorf("expected 'My Layout', got %q", got) + } +} + +func TestSanitizeNameStripsControlChars(t *testing.T) { + if got := sanitizeName("Evil\x00Name\x1B!"); got != "EvilName!" { + t.Errorf("expected 'EvilName!', got %q", got) + } +} + +func TestSanitizeNameTrimsWhitespace(t *testing.T) { + if got := sanitizeName(" padded "); got != "padded" { + t.Errorf("expected 'padded', got %q", got) + } +} + +func TestSanitizeNameTruncates(t *testing.T) { + long := strings.Repeat("x", 200) + got := sanitizeName(long) + if utf8.RuneCountInString(got) != maxNameLength { + t.Errorf("expected %d runes, got %d", maxNameLength, utf8.RuneCountInString(got)) + } + + // Multi-byte: 200 CJK characters → truncate to maxNameLength runes, not bytes + cjk := strings.Repeat("漢", 200) + gotCJK := sanitizeName(cjk) + if utf8.RuneCountInString(gotCJK) != maxNameLength { + t.Errorf("CJK: expected %d runes, got %d", maxNameLength, utf8.RuneCountInString(gotCJK)) + } + if !utf8.ValidString(gotCJK) { + t.Error("CJK truncation produced invalid UTF-8") + } +} + +func TestSanitizeNameEmptyFallback(t *testing.T) { + if got := sanitizeName(""); got != "Unnamed Layout" { + t.Errorf("expected 'Unnamed Layout', got %q", got) + } + // Only whitespace → also empty after trim + if got := sanitizeName(" "); got != "Unnamed Layout" { + t.Errorf("expected 'Unnamed Layout', got %q", got) + } + // Only control chars → also empty after stripping + if got := sanitizeName("\x00\x1F"); got != "Unnamed Layout" { + t.Errorf("expected 'Unnamed Layout', got %q", got) + } +} + +func TestSanitizeNamePreservesUnicode(t *testing.T) { + if got := sanitizeName("Ελληνικά 日本語"); got != "Ελληνικά 日本語" { + t.Errorf("expected unicode preserved, got %q", got) + } +} + +// --------------------------------------------------------------------------- +// loadBuiltinLayout +// --------------------------------------------------------------------------- + +func TestLoadBuiltinLayoutHyphenID(t *testing.T) { + layout, err := loadBuiltinLayout("en-US") + if err != nil { + t.Fatalf("failed to load en-US: %v", err) + } + if layout.ID != "en-US" { + t.Errorf("expected ID 'en-US', got %q", layout.ID) + } +} + +func TestLoadBuiltinLayoutAlias(t *testing.T) { + layout, err := loadBuiltinLayout("nl-BE") + if err != nil { + t.Fatalf("failed to load nl-BE alias: %v", err) + } + // nl-BE is an alias for fr_BE — should get the fr_BE layout but with ID "nl-BE" + if layout.ID != "nl-BE" { + t.Errorf("expected ID 'nl-BE', got %q", layout.ID) + } + if len(layout.Keys) == 0 { + t.Error("alias layout has no keys") + } +} + +func TestLoadBuiltinLayoutAliasMatchesDirect(t *testing.T) { + alias, err := loadBuiltinLayout("nl-BE") + if err != nil { + t.Fatalf("nl-BE: %v", err) + } + direct, err := loadBuiltinLayout("fr-BE") + if err != nil { + t.Fatalf("fr-BE: %v", err) + } + // Same keys, same charMap (different ID) + if len(alias.Keys) != len(direct.Keys) { + t.Errorf("key count mismatch: nl-BE=%d, fr-BE=%d", len(alias.Keys), len(direct.Keys)) + } + if len(alias.CharMap) != len(direct.CharMap) { + t.Errorf("charMap size mismatch: nl-BE=%d, fr-BE=%d", len(alias.CharMap), len(direct.CharMap)) + } +} + +func TestLoadBuiltinLayoutNotFound(t *testing.T) { + _, err := loadBuiltinLayout("xx-XX") + if err == nil { + t.Error("expected error for nonexistent layout") + } +} + +// --------------------------------------------------------------------------- +// selectPositionTable — compact KLE round-trip +// --------------------------------------------------------------------------- + +// Minimal 65% KLE: no function row, no numpad, no y:0.5 gap. +// Rows at y=0,1,2,3,4 (5 rows). Board width ~16u. +const compact65KLE = `[ + {"name": "Compact 65%", "author": "Test"}, + ["~\n\u0060","!\n1","@\n2","#\n3","$\n4","%\n5","^\n6","&\n7","*\n8","(\n9",")\n0","_\n-","+\n=",{"w":2},"⌫","Del"], + [{"w":1.5},"⭾","q","w","e","r","t","y","u","i","o","p","{\n[","}\n]",{"w":1.5},"|\n\\","PgUp"], + [{"w":1.75},"⇪","a","s","d","f","g","h","j","k","l",":\n;","'\n\"",{"w":2.25},"⏎","PgDn"], + [{"w":2.25},"⇧ Shift","z","x","c","v","b","n","m","<\n,",">\n.","?\n/",{"w":1.75},"⇧ Shift","↑","End"], + [{"w":1.25},"⌃ Ctrl",{"w":1.25},"⌘ Meta",{"w":1.25},"⌥ Alt",{"w":6.25},"Space",{"w":1},"⌥ Alt",{"w":1},"⌃ Ctrl","←","↓","→"] +]` + +// Minimal 75% KLE: function row packed at y=0, number row at y=1, no y:0.5 gap, no numpad. +const compact75KLE = `[ + {"name": "Compact 75%", "author": "Test"}, + ["Esc","F1","F2","F3","F4","F5","F6","F7","F8","F9","F10","F11","F12","PrtSc","Del"], + ["~\n\u0060","!\n1","@\n2","#\n3","$\n4","%\n5","^\n6","&\n7","*\n8","(\n9",")\n0","_\n-","+\n=",{"w":2},"⌫","Home"], + [{"w":1.5},"⭾","q","w","e","r","t","y","u","i","o","p","{\n[","}\n]",{"w":1.5},"|\n\\","PgUp"], + [{"w":1.75},"⇪","a","s","d","f","g","h","j","k","l",":\n;","'\n\"",{"w":2.25},"⏎","PgDn"], + [{"w":2.25},"⇧ Shift","z","x","c","v","b","n","m","<\n,",">\n.","?\n/",{"w":1.75},"⇧ Shift","↑","End"], + [{"w":1.25},"⌃ Ctrl",{"w":1.25},"⌘ Meta",{"w":1.25},"⌥ Alt",{"w":6.25},"Space",{"w":1},"⌥ Alt",{"w":1},"⌃ Ctrl","←","↓","→"] +]` + +func TestCompact65FallsBackToFullSize(t *testing.T) { + layout := mustParse(t, compact65KLE) + + if layout.BoardW > 20 { + t.Errorf("65%% should be narrow, got boardW=%.1f", layout.BoardW) + } + + // 65% boards (no function row, <70 keys or <6 rows) fall back to + // full-size table. Scancodes will be wrong for most keys, but the + // layout still parses — users need scancode overrides in metadata. + table := selectPositionTable(layout.BoardW, layout.BoardH, len(layout.Keys)) + hasNumpad := false + for _, entry := range table[2] { + if entry.xStart >= 18 { + hasNumpad = true + } + } + if !hasNumpad { + t.Error("65%% should fall back to full-size table (not compact)") + } + + if len(layout.Keys) == 0 { + t.Error("no keys parsed") + } + t.Logf("65%%: %d keys, falls back to full-size table (needs scancode overrides)", + len(layout.Keys)) +} + +func TestCompact75ParsesAndUsesCompactTable(t *testing.T) { + layout := mustParse(t, compact75KLE) + + if layout.BoardW > 20 { + t.Errorf("75%% should be narrow, got boardW=%.1f", layout.BoardW) + } + + // Function row at y=0 + escKey := findKey(layout, 0, 0) + if escKey == nil { + t.Fatal("Esc key not found at (0, 0)") + } + if escKey.Scancode != hidEscape { + t.Errorf("Esc scancode: expected 0x%02X, got 0x%02X", hidEscape, escKey.Scancode) + } + + f1Key := findKey(layout, 1, 0) + if f1Key == nil { + t.Fatal("F1 key not found at (1, 0)") + } + if f1Key.Scancode != hidF1 { + t.Errorf("F1 scancode: expected 0x%02X, got 0x%02X", hidF1, f1Key.Scancode) + } + + f12Key := findKey(layout, 12, 0) + if f12Key == nil { + t.Fatal("F12 key not found at (12, 0)") + } + if f12Key.Scancode != hidF12 { + t.Errorf("F12 scancode: expected 0x%02X, got 0x%02X", hidF12, f12Key.Scancode) + } + + // Number row at y=1 + n1Key := findKey(layout, 1, 1) + if n1Key == nil { + t.Fatal("1 key not found at (1, 1)") + } + if n1Key.Scancode != hidN1 { + t.Errorf("1 scancode: expected 0x%02X, got 0x%02X", hidN1, n1Key.Scancode) + } + + // QWERTY row at y=2 + qKey := findKey(layout, 1.5, 2) + if qKey == nil { + t.Fatal("Q key not found at (1.5, 2)") + } + if qKey.Scancode != hidQ { + t.Errorf("Q scancode: expected 0x%02X, got 0x%02X", hidQ, qKey.Scancode) + } + + // Home row at y=3 + enterKey := findKey(layout, 12.75, 3) + if enterKey == nil { + t.Fatal("Enter key not found at (12.75, 3)") + } + if enterKey.Scancode != hidEnter { + t.Errorf("Enter scancode: expected 0x%02X, got 0x%02X", hidEnter, enterKey.Scancode) + } + + t.Logf("75%%: %d keys, %d charMap entries, %.0fx%.0f", + len(layout.Keys), len(layout.CharMap), layout.BoardW, layout.BoardH) +} + +// --------------------------------------------------------------------------- +// Real-world compact KLE files (uploaded from keyboard-layout-editor.com) +// +// These use the STANDARD KLE convention: shift legend at position 0 (top), +// normal legend at position 1 (bottom). E.g. "!\n1" means shift=!, normal=1. +// Our built-in layouts use the OPPOSITE order: "1\n!" means normal=1, shift=!. +// These tests verify how the parser handles externally-authored KLE files. +// --------------------------------------------------------------------------- + +// Keycool 84 — real 75% keyboard from keyboard-layout-editor.com. +// Uses standard KLE legend order (shift-first): "!\n1" means shift=!, normal=1. +// Has "a":4 (bottom alignment) and "a":6 (center) properties. +// +// ANSI 60% — standard 60% ANSI. No function row, no nav cluster, no numpad. +// Uses standard KLE legend order (shift-first). Has "a":7 on spacebar. +// +// ISO 60% — 60% with ISO Enter (h=2, w2/x2 offset). No function row. +// Uses standard KLE legend order. Has ISO extra key between LShift and Z. +// +// These are stored as files to avoid Go string escaping headaches with +// backslashes, backticks, and quotes inside JSON. +// +// See: internal/keyboard/testdata/ + +func loadTestKLE(t *testing.T, name string) string { + t.Helper() + data, err := os.ReadFile("testdata/" + name) + if err != nil { + t.Fatalf("failed to read testdata/%s: %v", name, err) + } + return string(data) +} + +func TestKeycool84Parse(t *testing.T) { + layout := mustParse(t, loadTestKLE(t, "keycool_84.kle.json")) + + t.Logf("Keycool 84: %d keys, %d charMap, board %.1fx%.1f", + len(layout.Keys), len(layout.CharMap), layout.BoardW, layout.BoardH) + + // Should be compact (no numpad, narrow board) + if layout.BoardW > 20 { + t.Errorf("expected narrow board, got %.1f", layout.BoardW) + } + if len(layout.Keys) < 70 || len(layout.Keys) > 90 { + t.Errorf("expected ~84 keys, got %d", len(layout.Keys)) + } + if len(layout.CharMap) == 0 { + t.Error("charMap is empty") + } + + // Legend order: standard KLE "!\n1" → after swap: normal="1", shift="!" + n1Key := findKey(layout, 1, 1) + if n1Key == nil { + t.Fatal("key at (1,1) not found") + } + if n1Key.Legends.Normal == nil || *n1Key.Legends.Normal != "1" { + t.Errorf("number 1 key: expected normal='1', got %v", safeStr(n1Key.Legends.Normal)) + } + if n1Key.Legends.Shift == nil || *n1Key.Legends.Shift != "!" { + t.Errorf("number 1 key: expected shift='!', got %v", safeStr(n1Key.Legends.Shift)) + } + + // Uppercase "Q" → after swap + auto-lowercase: normal="q", shift="Q" + if combo, ok := layout.CharMap["q"]; !ok { + t.Error("'q' missing from charMap") + } else if combo.Modifiers != ModNone { + t.Errorf("'q' should be unmodified, got 0x%02X", combo.Modifiers) + } + if combo, ok := layout.CharMap["Q"]; !ok { + t.Error("'Q' missing from charMap") + } else if combo.Modifiers != ModLShift { + t.Errorf("'Q' should require Shift, got 0x%02X", combo.Modifiers) + } + + // Scancodes: 75% selects compact table, function row at y=0 + escKey := findKey(layout, 0, 0) + if escKey == nil { + t.Fatal("Esc not found") + } + if escKey.Scancode != hidEscape { + t.Errorf("Esc scancode: expected 0x%02X, got 0x%02X", hidEscape, escKey.Scancode) + } + + // F1 at x=1 (Keycool packs F-keys tightly) + f1Key := findKey(layout, 1, 0) + if f1Key == nil { + t.Fatal("F1 not found") + } + if f1Key.Scancode != hidF1 { + t.Errorf("F1 scancode: expected 0x%02X, got 0x%02X", hidF1, f1Key.Scancode) + } +} + +func TestANSI60Parse(t *testing.T) { + layout := mustParse(t, loadTestKLE(t, "ansi_60.kle.json")) + + t.Logf("ANSI 60%%: %d keys, %d charMap, board %.1fx%.1f", + len(layout.Keys), len(layout.CharMap), layout.BoardW, layout.BoardH) + + if len(layout.Keys) < 55 || len(layout.Keys) > 65 { + t.Errorf("expected ~61 keys, got %d", len(layout.Keys)) + } + + // --- Discovery: legend order --- + grave := findKey(layout, 0, 0) + if grave == nil { + t.Fatal("grave key at (0,0) not found") + } + t.Logf("Grave key legends: normal=%v, shift=%v", + safeStr(grave.Legends.Normal), safeStr(grave.Legends.Shift)) + if grave.Legends.Normal != nil && *grave.Legends.Normal == "~" { + t.Log("FINDING: shift-first legend order (normal='~' should be '`')") + } + + // --- Discovery: 60% row offset --- + // No function row: number row at y=0. Compact table row 0 = F-keys. + // So grave at (0,0) gets Escape scancode instead of Grave. + if grave.Scancode == hidEscape { + t.Log("FINDING: 60% row offset — grave key at y=0 gets Escape scancode " + + "(compact table expects F-row at y=0)") + } + + // ANSI Enter (w=2.25, single-row) + enterKey := findKey(layout, 12.75, 2) + if enterKey == nil { + t.Fatal("Enter key not found at (12.75, 2)") + } + if enterKey.Shape != ShapeNormal { + t.Logf("Enter shape: %s (ANSI enter is single-row, expected normal)", enterKey.Shape) + } + + // 60% falls back to full-size table which has no row 1 (y:0.5 gap). + // QWERTY row at y=1 gets scancode 0 → not added to charMap. + // This is expected: 60% keyboards need scancode overrides. + if _, ok := layout.CharMap["q"]; !ok { + t.Log("Expected: 'q' absent from charMap (60% row offset, scancode=0)") + } +} + +func TestISO60Parse(t *testing.T) { + layout := mustParse(t, loadTestKLE(t, "iso_60.kle.json")) + + t.Logf("ISO 60%%: %d keys, %d charMap, board %.1fx%.1f", + len(layout.Keys), len(layout.CharMap), layout.BoardW, layout.BoardH) + + // ISO Enter (h=2) — should be detected and shaped correctly + var isoEnter *TransportKey + for i := range layout.Keys { + if layout.Keys[i].H >= 2 && layout.Keys[i].X < 15 { + isoEnter = &layout.Keys[i] + break + } + } + if isoEnter == nil { + t.Fatal("ISO Enter (h>=2) not found") + } + if isoEnter.Scancode != hidEnter { + t.Errorf("ISO Enter scancode: expected 0x%02X, got 0x%02X", hidEnter, isoEnter.Scancode) + } + if isoEnter.Shape != ShapeISOEnter { + t.Errorf("ISO Enter shape: expected %q, got %q", ShapeISOEnter, isoEnter.Shape) + } + t.Logf("ISO Enter at (%.2f, %.2f), w=%.2f, h=%.2f, w2=%.2f, x2=%.2f", + isoEnter.X, isoEnter.Y, isoEnter.W, isoEnter.H, isoEnter.W2, isoEnter.X2) + + // ISO extra key between LShift and Z + isoExtra := findKey(layout, 1.25, 3) + if isoExtra == nil { + t.Fatal("ISO extra key at (1.25, 3) not found") + } + t.Logf("ISO extra key legends: normal=%v, shift=%v", + safeStr(isoExtra.Legends.Normal), safeStr(isoExtra.Legends.Shift)) + + // Legend order: "|\n\\" → our parser: normal="|", shift="\" + // Standard KLE intent: shift="|", normal="\" + if isoExtra.Legends.Normal != nil && *isoExtra.Legends.Normal == "|" { + t.Log("FINDING: ISO extra key has shift-first legend order " + + "(normal='|' should be '\\')") + } + + // Z should be at x=2.25 (after 1.25u LShift + 1u ISO key) + zKey := findKey(layout, 2.25, 3) + if zKey == nil { + t.Fatal("Z key not found at (2.25, 3)") + } + + // --- Discovery: 60% row offset applies here too --- + if zKey.Scancode != hidZ { + t.Logf("FINDING: Z scancode is 0x%02X (expected 0x%02X) — "+ + "60%% row offset (compact table expects F-row at y=0)", + zKey.Scancode, hidZ) + } +} + +// safeStr dereferences a *string for logging, returning "" if nil. +func safeStr(s *string) string { + if s == nil { + return "" + } + return *s +} + +// --------------------------------------------------------------------------- +// charMap +// --------------------------------------------------------------------------- + +func TestCharMapBasic(t *testing.T) { + // a key at (1.5, 2) with legends "q\nQ\n@" + keys := []TransportKey{ + {X: 1.5, Y: 2, W: 1, H: 1, Scancode: hidQ, + Legends: KeyLegends{Normal: str("q"), Shift: str("Q"), AltGr: str("@")}}, + } + m := buildCharMap(keys) + + if c, ok := m["q"]; !ok || c.Scancode != hidQ || c.Modifiers != ModNone { + t.Errorf("'q' mapping wrong: %+v", m["q"]) + } + if c, ok := m["Q"]; !ok || c.Modifiers != ModLShift { + t.Errorf("'Q' mapping wrong: %+v", m["Q"]) + } + if c, ok := m["@"]; !ok || c.Modifiers != ModAltGr { + t.Errorf("'@' mapping wrong: %+v", m["@"]) + } +} + +func TestCharMapSkipsMultiChar(t *testing.T) { + keys := []TransportKey{ + {X: 0, Y: 3, W: 1.75, H: 1, Scancode: hidCapsLock, + Legends: KeyLegends{Normal: str("Caps Lock")}}, + } + m := buildCharMap(keys) + if _, ok := m["Caps Lock"]; ok { + t.Error("multi-char legend 'Caps Lock' should not appear in charMap") + } +} + +func TestCharMapFirstOccurrenceWins(t *testing.T) { + // Two keys both producing "@" — first one (lower y) should win + keys := []TransportKey{ + {X: 1, Y: 1, W: 1, H: 1, Scancode: hidN2, Legends: KeyLegends{Shift: str("@")}}, + {X: 5, Y: 2, W: 1, H: 1, Scancode: hidT, Legends: KeyLegends{AltGr: str("@")}}, + } + m := buildCharMap(keys) + if c, ok := m["@"]; !ok || c.Scancode != hidN2 { + t.Errorf("first occurrence should win: got %+v", m["@"]) + } +} + +func TestCharMapExcludesScancode0(t *testing.T) { + keys := []TransportKey{ + {X: 0, Y: 0, W: 1, H: 1, Scancode: 0, + Legends: KeyLegends{Normal: str("x")}}, + } + m := buildCharMap(keys) + if _, ok := m["x"]; ok { + t.Error("key with Scancode=0 should not appear in charMap") + } +} + +// Lock the contracts of ScancodeProducesText and IsControlScancode. +func TestScancodeClassificationContract(t *testing.T) { + type tc struct { + name string + sc uint8 + producesTxt bool + controlLike bool + } + cases := []tc{ + // Text-producing keys + {"A", hidA, true, false}, + {"Z", hidZ, true, false}, + {"1", hidN1, true, false}, + {"0", hidN0, true, false}, + {"Space", hidSpace, true, true}, // text, but treated as control-like for layer logic + {"Minus", hidMinus, true, false}, + {"Slash", hidSlash, true, false}, + {"HashTilde", hidHashTilde, true, false}, + {"ISOKey", hidISOKey, true, false}, + {"KPSlash", hidKPSlash, true, false}, + {"KPPlus", hidKPPlus, true, false}, + {"KP1", hidKP1, true, false}, + {"KPDot", hidKPDot, true, false}, + // Non-text keys whose HID IDs fall inside the naïve printable ranges. + // These are the ones the old helper misclassified. + {"Enter", hidEnter, false, true}, + {"Escape", hidEscape, false, true}, + {"Backspace", hidBackspace, false, true}, + {"Tab", hidTab, false, true}, + {"NumLock", hidNumLock, false, true}, + {"KPEnter", hidKPEnter, false, true}, + // Modifier and navigation keys + {"LShift", hidLShift, false, true}, + {"ArrowUp", hidArrowUp, false, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := ScancodeProducesText(c.sc); got != c.producesTxt { + t.Errorf("ScancodeProducesText(%s/0x%02x) = %v, want %v", c.name, c.sc, got, c.producesTxt) + } + if got := IsControlScancode(c.sc); got != c.controlLike { + t.Errorf("IsControlScancode(%s/0x%02x) = %v, want %v", c.name, c.sc, got, c.controlLike) + } + }) + } +} + +func TestIsControlScancodeISOKey(t *testing.T) { + // hidISOKey (0x64) sits past the old naïve printable range, needs explicit confirmation + if IsControlScancode(hidISOKey) { + t.Errorf("IsControlScancode(hidISOKey) = true, want false (it is a text-producing key)") + } +} + +// IsControlScancode must return true for every scancode the frontend needs +// to consider control-like for keycap render decisions. +func TestIsControlScancodeCoversFormerExplicitSet(t *testing.T) { + cases := map[string]uint8{ + "Escape": hidEscape, + "Enter": hidEnter, + "Backspace": hidBackspace, + "Tab": hidTab, + "Space": hidSpace, + "CapsLock": hidCapsLock, + "ScrollLock": hidScrollLock, + "NumLock": hidNumLock, + "PrintScreen": hidPrintScreen, + "Pause": hidPause, + "Insert": hidInsert, + "Delete": hidDelete, + "Home": hidHome, + "End": hidEnd, + "PageUp": hidPageUp, + "PageDown": hidPageDown, + "ArrowUp": hidArrowUp, + "ArrowDown": hidArrowDown, + "ArrowLeft": hidArrowLeft, + "ArrowRight": hidArrowRight, + "NumpadEnter": hidKPEnter, + "Application": hidApplication, + } + for name, sc := range cases { + t.Run(name, func(t *testing.T) { + if !IsControlScancode(sc) { + t.Errorf("IsControlScancode(%s/0x%02x) = false, want true", name, sc) + } + }) + } +} + +// When the same dead-key character appears on multiple layers (or on +// multiple physical keys), addDeadKeyCompositions must use one consistent +// combo for both the composition entries (â) and the standalone replacement +// (^). Picking simplest modifier first guarantees that. +func TestDeadKeyCompositionsPickSimplestLayer(t *testing.T) { + declared := map[rune]bool{'^': true} + keys := []TransportKey{ + // Key Y: ^ is on AltGr (mods != 0). Encountered first because of + // input order — would be picked under the old "input-order wins" + // logic. + {X: 0, Y: 0, W: 1, H: 1, Scancode: hidY, + Legends: KeyLegends{Normal: str("y"), Shift: str("Y"), AltGr: str("^")}}, + // Key 6: ^ is on Shift (still has a modifier, but simpler than AltGr). + {X: 1, Y: 0, W: 1, H: 1, Scancode: hidN6, + Legends: KeyLegends{Normal: str("6"), Shift: str("^")}}, + // 'a' base + {X: 0, Y: 1, W: 1, H: 1, Scancode: hidA, + Legends: KeyLegends{Normal: str("a"), Shift: str("A")}}, + } + charMap := buildCharMap(keys) + addDeadKeyCompositions(keys, charMap, declared) + + want := HIDCombo{Scancode: hidN6, Modifiers: ModLShift} + + // Composition: â must use the simplest-layer ^ (Shift on key 6). + aHat, ok := charMap["â"] + if !ok { + t.Fatalf("charMap[â] missing — composition didn't run") + } + if aHat.Prefix == nil { + t.Fatalf("charMap[â].Prefix nil — should be a dead-key composition") + } + if *aHat.Prefix != want { + t.Errorf("charMap[â].Prefix = %+v, want %+v (simpler Shift layer should beat AltGr)", *aHat.Prefix, want) + } + + // Standalone: ^ must use the same combo so the two paths agree. + hat, ok := charMap["^"] + if !ok { + t.Fatalf("charMap[^] missing") + } + if hat.Prefix == nil { + t.Fatalf("charMap[^].Prefix nil — standalone dead key must be prefixed") + } + if *hat.Prefix != want { + t.Errorf("charMap[^].Prefix = %+v, want %+v", *hat.Prefix, want) + } +} + +func TestDeadKeyCompositionsPreferNormalOverShift(t *testing.T) { + // When ^ appears on both Normal and AltGr layers of the same physical key, + // Normal (no modifier) wins for both compositions and standalone. + declared := map[rune]bool{'^': true} + keys := []TransportKey{ + {X: 0, Y: 0, W: 1, H: 1, Scancode: hidY, + Legends: KeyLegends{Normal: str("^"), AltGr: str("^")}}, + {X: 0, Y: 1, W: 1, H: 1, Scancode: hidA, Legends: KeyLegends{Normal: str("a")}}, + } + charMap := buildCharMap(keys) + addDeadKeyCompositions(keys, charMap, declared) + + want := HIDCombo{Scancode: hidY, Modifiers: ModNone} + for _, ch := range []string{"â", "^"} { + entry, ok := charMap[ch] + if !ok { + t.Fatalf("charMap[%q] missing", ch) + } + if entry.Prefix == nil || *entry.Prefix != want { + got := "" + if entry.Prefix != nil { + got = fmt.Sprintf("%+v", *entry.Prefix) + } + t.Errorf("charMap[%q].Prefix = %s, want %+v", ch, got, want) + } + } +} + +func TestCharMapMapsPasteableControlChars(t *testing.T) { + // Newline, CR, CRLF, and tab in pasted text need charMap entries because + // PasteModal looks up each segmented codepoint verbatim. The standard + // addChar path filters runes < U+0020, so these have to be added explicitly. + keys := []TransportKey{ + {X: 0, Y: 0, W: 1, H: 1, Scancode: hidA, Legends: KeyLegends{Normal: str("a")}}, + {X: 1, Y: 0, W: 1, H: 1, Scancode: hidEnter, Legends: KeyLegends{Normal: str("⏎")}}, + {X: 2, Y: 0, W: 1, H: 1, Scancode: hidTab, Legends: KeyLegends{Normal: str("⭾")}}, + } + m := buildCharMap(keys) + + cases := []struct { + name string + key string + want uint8 + }{ + {"newline", "\n", hidEnter}, + {"carriage return", "\r", hidEnter}, + {"CRLF (Intl.Segmenter grapheme cluster)", "\r\n", hidEnter}, + {"tab", "\t", hidTab}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c, ok := m[tc.key] + if !ok { + t.Fatalf("charMap[%q] missing", tc.key) + } + if c.Scancode != tc.want { + t.Errorf("charMap[%q].Scancode = 0x%02x, want 0x%02x", tc.key, c.Scancode, tc.want) + } + if c.Modifiers != ModNone { + t.Errorf("charMap[%q].Modifiers = 0x%02x, want 0", tc.key, c.Modifiers) + } + }) + } +} + +func TestCharMapOmitsControlCharsWhenScancodeAbsent(t *testing.T) { + // If a layout lacks an Enter key (e.g. a decal-only test fixture) we should + // not invent a charMap entry pointing at hidEnter — the paste would target + // a key that isn't on the keyboard. + keys := []TransportKey{ + {X: 0, Y: 0, W: 1, H: 1, Scancode: hidA, Legends: KeyLegends{Normal: str("a")}}, + } + m := buildCharMap(keys) + for _, k := range []string{"\n", "\r", "\r\n", "\t"} { + if _, ok := m[k]; ok { + t.Errorf("charMap[%q] should be absent when no Enter/Tab key exists", k) + } + } +} + +func TestCharMapMapsSpaceCharRegardlessOfLegend(t *testing.T) { + // The Space key's legend is variable across layouts: kbdlayout.info uses "␠", + // the built-in layouts use "Space" (5 chars), some sources use literal " ". + // In all cases, pasting a literal space (U+0020) must work. + cases := []struct { + name string + legend string + }{ + {"display label", "Space"}, + {"control glyph", "␠"}, + {"literal space", " "}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + keys := []TransportKey{ + {X: 0, Y: 0, W: 6.25, H: 1, Scancode: hidSpace, + Legends: KeyLegends{Normal: str(tc.legend)}}, + } + m := buildCharMap(keys) + c, ok := m[" "] + if !ok { + t.Fatalf("charMap[\" \"] missing for legend %q (entries=%v)", tc.legend, m) + } + if c.Scancode != hidSpace || c.Modifiers != ModNone { + t.Errorf("charMap[\" \"] = %+v, want {Scancode:hidSpace, Modifiers:0}", c) + } + }) + } +} + +func TestCharMapSkipsGlyphLegendsForNonTextKeys(t *testing.T) { + keys := []TransportKey{ + {X: 0, Y: 0, W: 1, H: 1, Scancode: hidBackspace, + Legends: KeyLegends{Normal: str("⌫")}}, + {X: 1, Y: 0, W: 1, H: 1, Scancode: hidTab, + Legends: KeyLegends{Normal: str("⭾")}}, + {X: 2, Y: 0, W: 1, H: 1, Scancode: hidArrowUp, + Legends: KeyLegends{Normal: str("↑")}}, + {X: 3, Y: 0, W: 1, H: 1, Scancode: hidLShift, + Legends: KeyLegends{Normal: str("⇧")}}, + {X: 4, Y: 0, W: 1, H: 1, Scancode: hidQ, + Legends: KeyLegends{Normal: str("q")}}, + } + m := buildCharMap(keys) + + for _, glyph := range []string{"⌫", "⭾", "↑", "⇧"} { + if _, ok := m[glyph]; ok { + t.Errorf("non-text glyph legend %q should not appear in charMap", glyph) + } + } + + if _, ok := m["q"]; !ok { + t.Error("printable key legend 'q' should still appear in charMap") + } +} + +// --------------------------------------------------------------------------- +// Full round-trip: parse → JSON marshal → unmarshal +// --------------------------------------------------------------------------- + +func TestRoundTrip(t *testing.T) { + layout := mustParse(t, minimalKLE) + + data, err := json.Marshal(layout) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var restored KeyboardLayout + if err := json.Unmarshal(data, &restored); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if restored.Name != layout.Name { + t.Errorf("name mismatch: %q vs %q", restored.Name, layout.Name) + } + if len(restored.Keys) != len(layout.Keys) { + t.Errorf("key count mismatch: %d vs %d", len(restored.Keys), len(layout.Keys)) + } +} + +func TestRoundTripWithDeadKeys(t *testing.T) { + layout, err := loadBuiltinLayout("de-DE") + if err != nil { + t.Fatalf("failed to load de-DE: %v", err) + } + + data, err := json.Marshal(layout) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var restored KeyboardLayout + if err := json.Unmarshal(data, &restored); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + // Dead key composition should survive the round-trip + combo, ok := restored.CharMap["â"] + if !ok { + t.Fatal("â missing from charMap after round-trip") + } + if combo.Prefix == nil { + t.Fatal("â dead key prefix lost in round-trip") + } + if combo.Prefix.Scancode == 0 { + t.Error("prefix scancode is zero after round-trip") + } + // Prefix should not have its own prefix (no nested dead keys) + if combo.Prefix.Prefix != nil { + t.Error("prefix should not have a nested prefix") + } +} + +// --------------------------------------------------------------------------- +// Validation errors +// --------------------------------------------------------------------------- + +func TestValidationRejectsEmptyLayout(t *testing.T) { + // Only metadata, no rows + _, err := ParseKLE([]byte(`[{"name":"Empty"}]`), "x", "") + if err == nil || !strings.Contains(err.Error(), "no keys") { + t.Errorf("expected 'no keys' error, got: %v", err) + } +} + +func TestNameOverride(t *testing.T) { + layout := mustParse(t, minimalKLE) + layout2, _ := ParseKLE([]byte(minimalKLE), "test", "Override Name") + if layout2.Name != "Override Name" { + t.Errorf("expected 'Override Name', got %q", layout2.Name) + } + _ = layout +} + +func TestDeadKeyDetection(t *testing.T) { + // With declared dead keys, only matching legends get flagged + declared := map[rune]bool{'^': true, '´': true} + + // ^ in Normal slot — should be detected + slots := getDeadKeyInfo(KeyLegends{Normal: str("^")}, declared) + if !contains(slots, "normal") { + t.Error("^ in Normal should be detected") + } + + // 'a' is not declared — should not be detected + slots = getDeadKeyInfo(KeyLegends{Normal: str("a")}, declared) + if len(slots) > 0 { + t.Error("'a' should not be detected as dead") + } + + // ~ is not declared — should not be detected + slots = getDeadKeyInfo(KeyLegends{Normal: str("~")}, declared) + if len(slots) > 0 { + t.Error("~ should not be detected as dead when not declared") + } + + // With empty declared set, nothing is dead + slots = getDeadKeyInfo(KeyLegends{Normal: str("^")}, nil) + if len(slots) > 0 { + t.Error("^ should not be detected with no declarations") + } + + // nil Normal legend — not detected in Normal, but Shift is checked + slots = getDeadKeyInfo(KeyLegends{Normal: nil, Shift: str("^")}, declared) + if !contains(slots, "shift") { + t.Error("^ in Shift should be detected even if Normal is nil") + } + + // Dead key on shift layer only — now detected in "shift" slot + slots = getDeadKeyInfo(KeyLegends{Normal: str("°"), Shift: str("^")}, declared) + if !contains(slots, "shift") { + t.Error("^ on Shift layer should be detected in 'shift' slot") + } + if contains(slots, "normal") { + t.Error("° in Normal should not be detected as dead") + } +} + +// --------------------------------------------------------------------------- +// Dead key compositions +// --------------------------------------------------------------------------- + +func TestDeadKeyComposition(t *testing.T) { + // Load German layout — has ^ and ´ dead keys + layout, err := loadBuiltinLayout("de-DE") + if err != nil { + t.Fatalf("failed to load de-DE: %v", err) + } + + // â should exist: ^ (dead) + a → â + combo, ok := layout.CharMap["â"] + if !ok { + t.Fatal("â not found in charMap") + } + if combo.Prefix == nil { + t.Fatal("â should have a dead key prefix") + } + if combo.Scancode != hidA { + t.Errorf("â base scancode: expected 0x%02X (a), got 0x%02X", hidA, combo.Scancode) + } + + // Ê should exist: ^ (dead) + Shift+E → Ê + comboUpper, ok := layout.CharMap["Ê"] + if !ok { + t.Fatal("Ê not found in charMap") + } + if comboUpper.Prefix == nil { + t.Fatal("Ê should have a dead key prefix") + } + if comboUpper.Modifiers&ModLShift == 0 { + t.Error("Ê should require Shift modifier on the base key") + } + + // ^ standalone should have prefix + Space + caret, ok := layout.CharMap["^"] + if !ok { + t.Fatal("^ not found in charMap") + } + if caret.Prefix == nil { + t.Fatal("^ should have a dead key prefix (standalone dead key)") + } + if caret.Scancode != hidSpace { + t.Errorf("^ standalone should send Space (0x%02X), got 0x%02X", hidSpace, caret.Scancode) + } + + // Regular character should NOT have a prefix + plain, ok := layout.CharMap["a"] + if !ok { + t.Fatal("a not found in charMap") + } + if plain.Prefix != nil { + t.Error("plain 'a' should not have a dead key prefix") + } +} + +func TestNoDeadKeysMetadataProducesNoPrefixes(t *testing.T) { + // Layouts without deadKeys metadata should have: + // - No keys with DeadLegends set (empty slice) + // - No charMap entries with a Prefix (no compositions) + // This prevents e.g. en-US from treating ^ as a dead key during paste. + noDeadKeyLayouts := []string{"en-US", "en-UK", "it-IT", "ja-JP", "pl-PL", "ru-RU"} + + for _, id := range noDeadKeyLayouts { + t.Run(id, func(t *testing.T) { + layout, err := loadBuiltinLayout(id) + if err != nil { + t.Fatalf("failed to load %s: %v", id, err) + } + for _, key := range layout.Keys { + if len(key.DeadLegends) > 0 { + t.Errorf("key at (%.1f, %.1f) has dead legends but %s has no deadKeys metadata", + key.X, key.Y, id) + } + } + for ch, combo := range layout.CharMap { + if combo.Prefix != nil { + t.Errorf("charMap[%q] has prefix but %s declares no dead keys", ch, id) + } + } + }) + } +} + +func TestDeadKeysMetadataFlagsCorrectKeys(t *testing.T) { + // Layouts WITH deadKeys metadata should flag keys whose legend + // (in any slot) matches a declared dead key. + layout, err := loadBuiltinLayout("de-DE") + if err != nil { + t.Fatalf("failed to load de-DE: %v", err) + } + + deadCount := 0 + for _, key := range layout.Keys { + if len(key.DeadLegends) > 0 { + deadCount++ + // Every flagged key must have at least one legend that could be dead + hasLegend := key.Legends.Normal != nil || key.Legends.Shift != nil || + key.Legends.AltGr != nil || key.Legends.ShiftAltGr != nil || + key.Legends.Kana != nil || key.Legends.ShiftKana != nil + if !hasLegend { + t.Errorf("dead key at (%.1f, %.1f) has no legends", key.X, key.Y) + } + } + } + if deadCount == 0 { + t.Error("de-DE should have dead-flagged keys") + } + t.Logf("de-DE: %d keys flagged as dead", deadCount) +} + +func TestDeadKeyNonComposingPairSkipped(t *testing.T) { + // de_DE has ^ dead key. ^+k has no precomposed Unicode form, + // so "k̂" (k + combining circumflex) should NOT be in charMap. + layout, err := loadBuiltinLayout("de-DE") + if err != nil { + t.Fatalf("failed to load de-DE: %v", err) + } + + // k̂ (U+006B U+0302) — no NFC composition exists + if _, ok := layout.CharMap["k\u0302"]; ok { + t.Error("k + combining circumflex should not be in charMap (no NFC form)") + } + // But â (U+00E2) should exist — it has a precomposed form + if _, ok := layout.CharMap["â"]; !ok { + t.Error("â should be in charMap (valid NFC composition)") + } +} + +func TestDeadKeyCompositionsFrench(t *testing.T) { + // fr_FR has ^ and ¨ dead keys + layout, err := loadBuiltinLayout("fr-FR") + if err != nil { + t.Fatalf("failed to load fr-FR: %v", err) + } + + // ^ + e → ê + if combo, ok := layout.CharMap["ê"]; !ok { + t.Error("ê not found in charMap") + } else if combo.Prefix == nil { + t.Error("ê should have a dead key prefix") + } + + // ^ + a → â + if combo, ok := layout.CharMap["â"]; !ok { + t.Error("â not found in charMap") + } else if combo.Prefix == nil { + t.Error("â should have a dead key prefix") + } + + // ¨ + e → ë + if combo, ok := layout.CharMap["ë"]; !ok { + t.Error("ë not found in charMap") + } else if combo.Prefix == nil { + t.Error("ë should have a dead key prefix") + } + + // ¨ + u → ü + if combo, ok := layout.CharMap["ü"]; !ok { + t.Error("ü not found in charMap") + } else if combo.Prefix == nil { + t.Error("ü should have a dead key prefix") + } + + // Uppercase: ^ + Shift+E → Ê + if combo, ok := layout.CharMap["Ê"]; !ok { + t.Error("Ê not found in charMap") + } else { + if combo.Prefix == nil { + t.Error("Ê should have a dead key prefix") + } + if combo.Modifiers&ModLShift == 0 { + t.Error("Ê should require Shift on the base key") + } + } +} + +func TestDeadKeyCompositionsSpanish(t *testing.T) { + // es_ES has ´, ^, `, ¨ dead keys + layout, err := loadBuiltinLayout("es-ES") + if err != nil { + t.Fatalf("failed to load es-ES: %v", err) + } + + // ´ + a → á + if combo, ok := layout.CharMap["á"]; !ok { + t.Error("á not found in charMap") + } else if combo.Prefix == nil { + t.Error("á should have a dead key prefix") + } + + // ` + e → è + if combo, ok := layout.CharMap["è"]; !ok { + t.Error("è not found in charMap") + } else if combo.Prefix == nil { + t.Error("è should have a dead key prefix") + } + + // ¨ + u → ü + if combo, ok := layout.CharMap["ü"]; !ok { + t.Error("ü not found in charMap") + } else if combo.Prefix == nil { + t.Error("ü should have a dead key prefix") + } + + // Uppercase: ´ + Shift+A → Á + if combo, ok := layout.CharMap["Á"]; !ok { + t.Error("Á not found in charMap") + } else { + if combo.Prefix == nil { + t.Error("Á should have a dead key prefix") + } + if combo.Modifiers&ModLShift == 0 { + t.Error("Á should require Shift on the base key") + } + } +} + +func TestDeadKeyCompositionsCzech(t *testing.T) { + // cs_CZ has the most dead keys: ´, ˇ, ^, `, ~, °, ˙, ˛, ¸, ¨ + layout, err := loadBuiltinLayout("cs-CZ") + if err != nil { + t.Fatalf("failed to load cs-CZ: %v", err) + } + + // Czech has š, ů, etc. as DIRECT key legends (number row), so they + // appear in charMap without a prefix (first-occurrence-wins). + // Verify they're reachable (either directly or via composition). + for _, ch := range []string{"š", "Š", "ů", "č", "ř", "ž", "ý", "á", "í", "é"} { + if _, ok := layout.CharMap[ch]; !ok { + t.Errorf("%s not found in charMap", ch) + } + } + + // Characters NOT on a direct key should come via dead key composition. + // ^ + a → â (not a direct Czech key) + if combo, ok := layout.CharMap["â"]; !ok { + t.Error("â not found in charMap") + } else if combo.Prefix == nil { + t.Error("â should have a dead key prefix (not a direct Czech key)") + } + + // ¨ + o → ö (not a direct Czech key) + if combo, ok := layout.CharMap["ö"]; !ok { + t.Error("ö not found in charMap") + } else if combo.Prefix == nil { + t.Error("ö should have a dead key prefix (not a direct Czech key)") + } + + t.Logf("cs-CZ: %d charMap entries (richest dead key set)", len(layout.CharMap)) +} + +func TestDeadKeyCompositionsSlovenian(t *testing.T) { + // sl_SI has ¸ (cedilla) and ¨ (diaeresis) dead keys + layout, err := loadBuiltinLayout("sl-SI") + if err != nil { + t.Fatalf("failed to load sl-SI: %v", err) + } + + // ¸ + c → ç + if combo, ok := layout.CharMap["ç"]; !ok { + t.Error("ç not found in charMap") + } else if combo.Prefix == nil { + t.Error("ç should have a dead key prefix") + } + + // ¨ + a → ä + if combo, ok := layout.CharMap["ä"]; !ok { + t.Error("ä not found in charMap") + } else if combo.Prefix == nil { + t.Error("ä should have a dead key prefix") + } +} + +func TestDeadKeyCompositionsHungarian(t *testing.T) { + // hu_HU has ´, ˝ (double acute), ¨ dead keys + layout, err := loadBuiltinLayout("hu-HU") + if err != nil { + t.Fatalf("failed to load hu-HU: %v", err) + } + + // Hungarian has ő, ű, é, á, etc. as DIRECT key legends, so they + // appear in charMap without a prefix (first-occurrence-wins). + for _, ch := range []string{"ő", "ű", "é", "á", "ú", "ó"} { + if _, ok := layout.CharMap[ch]; !ok { + t.Errorf("%s not found in charMap", ch) + } + } + + // ä should exist in the charMap. Depending on source data/version, + // it may be present either as a direct legend or via dead-key composition. + if combo, ok := layout.CharMap["ä"]; !ok { + t.Error("ä not found in charMap") + } else if combo.Prefix == nil { + t.Log("ä is direct on hu-HU in this layout source") + } + + // ´ + i → í (not a direct Hungarian key on most layouts) + if _, ok := layout.CharMap["í"]; !ok { + t.Error("í not found in charMap") + } +} + +// --------------------------------------------------------------------------- +// Built-in layout loading +// --------------------------------------------------------------------------- + +func TestAllBuiltinLayoutsParse(t *testing.T) { + for id := range builtinLayouts { + t.Run(id, func(t *testing.T) { + layout, err := loadBuiltinLayout(id) + if err != nil { + t.Fatalf("failed to load built-in layout %s: %v", id, err) + } + if layout.ID != id { + t.Errorf("expected ID %q, got %q", id, layout.ID) + } + if len(layout.Keys) == 0 { + t.Error("layout has no keys") + } + if len(layout.CharMap) == 0 { + t.Error("layout has empty charMap") + } + t.Logf("%s: %d keys, %d charMap entries, %.0fx%.0f", + layout.Name, len(layout.Keys), len(layout.CharMap), layout.BoardW, layout.BoardH) + }) + } +} + +// TestAllLayoutFilesRegistered scans the embedded layouts/ directory and +// verifies that every .kle.json file can be parsed and that a corresponding +// entry exists in the builtinLayouts map (directly or via alias). +func TestAllLayoutFilesRegistered(t *testing.T) { + entries, err := layoutFS.ReadDir("layouts") + if err != nil { + t.Fatalf("failed to read embedded layouts dir: %v", err) + } + + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || len(name) < len(".kle.json") { + continue + } + if name[len(name)-len(".kle.json"):] != ".kle.json" { + continue + } + stem := name[:len(name)-len(".kle.json")] // e.g. "en_US" + + t.Run(stem, func(t *testing.T) { + // Verify the file parses + data, err := layoutFS.ReadFile("layouts/" + name) + if err != nil { + t.Fatalf("failed to read %s: %v", name, err) + } + layout, err := ParseKLE(data, stem, "") + if err != nil { + t.Fatalf("failed to parse %s: %v", name, err) + } + if len(layout.Keys) == 0 { + t.Errorf("%s has no keys", name) + } + + // Verify it's reachable from builtinLayouts (direct or alias) + hyphenated := strings.ReplaceAll(stem, "_", "-") + if _, ok := builtinLayouts[hyphenated]; ok { + return // direct match + } + // Check if any alias points to this file + for alias, target := range layoutAliases { + if target == stem { + if _, ok := builtinLayouts[alias]; ok { + return // reachable via alias + } + } + } + t.Errorf("layout file %s (id %q) has no entry in builtinLayouts", name, hyphenated) + }) + } +} + +// TestSpecialKeysAliasesUniqueAndCanonical asserts the keyaliases.json +// invariants the init() panics rely on: every alias and canonical maps to +// exactly one SpecialKey, and the AriaKey/Canonical fields are non-empty. +// init() already enforces this on parse; this test gives a clean failure +// message instead of a panic on `go test`. +func TestSpecialKeysAliasesUniqueAndCanonical(t *testing.T) { + seen := map[string]string{} // legend → ariaKey of owning entry + for _, sk := range SpecialKeys { + if sk.AriaKey == "" { + t.Errorf("entry with empty ariaKey: %+v", sk) + } + if sk.Canonical == "" { + t.Errorf("entry %q has empty canonical", sk.AriaKey) + } + all := append([]string{sk.Canonical}, sk.Aliases...) + for _, leg := range all { + if owner, dup := seen[leg]; dup { + t.Errorf("legend %q claimed by both %q and %q", leg, owner, sk.AriaKey) + } + seen[leg] = sk.AriaKey + } + } +} + +// TestParseKeyAliasesRejectsDuplicates verifies that parseKeyAliases panics +// when the JSON declares two entries that share a canonical, or when a +// canonical collides with another entry's alias. The post-load +// TestSpecialKeysAliasesUniqueAndCanonical only inspects the production data; +// this test guards the parser itself so a future refactor can't reintroduce +// silent last-write-wins behaviour. +func TestParseKeyAliasesRejectsDuplicates(t *testing.T) { + cases := []struct { + name string + json string + want string + }{ + { + name: "duplicate canonical", + json: `{"specialKeys":[ + {"ariaKey":"enter","canonical":"Enter","aliases":[]}, + {"ariaKey":"return","canonical":"Enter","aliases":[]} + ]}`, + want: "declared by multiple entries", + }, + { + name: "canonical collides with alias of other entry", + json: `{"specialKeys":[ + {"ariaKey":"enter","canonical":"Enter","aliases":["Return"]}, + {"ariaKey":"return","canonical":"Return","aliases":[]} + ]}`, + want: "collides with alias", + }, + { + name: "alias claimed by two entries", + json: `{"specialKeys":[ + {"ariaKey":"enter","canonical":"Enter","aliases":["⏎"]}, + {"ariaKey":"return","canonical":"Return","aliases":["⏎"]} + ]}`, + want: "used by both", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + defer func() { + r := recover() + if r == nil { + t.Fatalf("expected panic, got none") + } + msg, _ := r.(string) + if !strings.Contains(msg, tc.want) { + t.Errorf("panic %q does not contain %q", msg, tc.want) + } + }() + parseKeyAliases([]byte(tc.json)) + }) + } +} + +// TestBuiltinLayoutLegendsAreKnown is the drift guard. For every built-in +// layout, every legend on a non-text-producing key (and on Space) must be +// either: +// - a single printable rune (these don't need normalization), or +// - a known canonical / alias from keyaliases.json, or +// - matched by PassthroughLegendPattern (F-keys). +// +// A failure means a layout introduced a new alias the taxonomy doesn't know +// about. Either add the alias to keyaliases.json (preferred) or change the +// layout file to use a recognized form. +func TestBuiltinLayoutLegendsAreKnown(t *testing.T) { + entries, err := layoutFS.ReadDir("layouts") + if err != nil { + t.Fatalf("read layouts dir: %v", err) + } + + type unknownLegend struct { + layout, legend string + } + var unknown []unknownLegend + + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".kle.json") { + continue + } + stem := strings.TrimSuffix(name, ".kle.json") + data, err := layoutFS.ReadFile("layouts/" + name) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + layout, err := ParseKLE(data, stem, "") + if err != nil { + t.Fatalf("parse %s: %v", name, err) + } + + for _, k := range layout.Keys { + if k.Scancode == 0 { + continue + } + // Skip text-producing keys except Space — their legends are + // the actual characters they produce and are handled by addChar. + if ScancodeProducesText(k.Scancode) && k.Scancode != hidSpace { + continue + } + legends := []*string{ + k.Legends.Normal, k.Legends.Shift, + k.Legends.AltGr, k.Legends.ShiftAltGr, + k.Legends.Kana, k.Legends.ShiftKana, + } + for _, leg := range legends { + if leg == nil || *leg == "" { + continue + } + if utf8.RuneCountInString(*leg) == 1 { + continue // single rune; no aria translation needed + } + if IsKnownSpecialLegend(*leg) { + continue + } + unknown = append(unknown, unknownLegend{stem, *leg}) + } + } + } + + if len(unknown) > 0 { + // Dedupe for readable output. + dedup := map[string][]string{} + for _, u := range unknown { + dedup[u.legend] = append(dedup[u.legend], u.layout) + } + for legend, layouts := range dedup { + t.Errorf("legend %q in layouts %v is not in keyaliases.json (add it as an alias of an existing entry, or as a new SpecialKey)", legend, layouts) + } + } +} diff --git a/internal/keyboard/layouts/cs_CZ.kle.json b/internal/keyboard/layouts/cs_CZ.kle.json new file mode 100644 index 000000000..1ccbf5b31 --- /dev/null +++ b/internal/keyboard/layouts/cs_CZ.kle.json @@ -0,0 +1,247 @@ +[ + { + "name": "Čeština cs-CZ (ISO 105)", + "author": "JetKVM", + "deadKeys": [ + "´", + "ˇ", + "^", + "`", + "~", + "°", + "˙", + "˛", + "¸", + "¨" + ], + "kbdLayoutInfo": "https://kbdlayout.info/00000405/" + }, + [ + "Esc", + { + "x": 1 + }, + "F1", + "F2", + "F3", + "F4", + { + "x": 0.5 + }, + "F5", + "F6", + "F7", + "F8", + { + "x": 0.5 + }, + "F9", + "F10", + "F11", + "F12", + { + "x": 0.25 + }, + "PrtSc", + "ScrLk", + "Pause", + { + "x": 0.25, + "d": true, + "w": 4 + }, + "Čeština\ncs-CZ\n\n(ISO 105)" + ], + [ + { + "y": 0.5 + }, + "°\n;", + "1\n+\n\n~", + "2\ně\n\nˇ", + "3\nš\n\n^", + "4\nč\n\n˘", + "5\nř\n\n°", + "6\nž\n\n˛", + "7\ný\n\n`", + "8\ná\n\n˙", + "9\ní\n\n´", + "0\né\n\n˝", + "%\n=\n\n¨", + "ˇ\n´\n\n¸", + { + "w": 2 + }, + "⌫", + { + "x": 0.25 + }, + "Ins", + "Home", + "PgUp", + { + "x": 0.25 + }, + "Num", + "/", + "*", + "-" + ], + [ + { + "w": 1.5 + }, + "⭾", + "Q\nq\n\n\\", + "W\nw\n\n|", + "E\ne\n\n€", + "R\nr", + "T\nt", + "Z\nz", + "U\nu", + "I\ni", + "O\no", + "P\np", + "/\nú\n\n÷", + "(\n)\n\n×", + { + "x": 0.25, + "w": 1.25, + "h": 2, + "w2": 1.5, + "h2": 1, + "x2": -0.25 + }, + "⏎", + { + "x": 0.25 + }, + "Del", + "End", + "PgDn", + { + "x": 0.25 + }, + "7", + "8", + "9", + { + "h": 2 + }, + "+" + ], + [ + { + "w": 1.75 + }, + "⇪", + "A\na", + "S\ns\n\nđ", + "D\nd\n\nĐ", + { + "n": true + }, + "F\nf\n\n[", + "G\ng\n\n]", + "H\nh", + { + "n": true + }, + "J\nj", + "K\nk\n\nł", + "L\nl\n\nŁ", + "\"\nů\n\n$", + "!\n§\n\nß", + "'\n¨\n\n¤", + { + "x": 4.75 + }, + "4", + { + "n": true + }, + "5", + "6" + ], + [ + { + "w": 1.25 + }, + "⇧ Shift", + "|\n\\\n\n", + "Y\ny", + "X\nx\n\n#", + "C\nc\n\n&", + "V\nv\n\n@", + "B\nb\n\n{", + "N\nn\n\n}", + "M\nm", + "?\n,\n\n<", + ":\n.\n\n>", + "_\n-\n\n*", + { + "w": 2.75 + }, + "⇧ Shift", + { + "x": 1.25 + }, + "↑", + { + "x": 1.25 + }, + "1", + "2", + "3", + { + "h": 2 + }, + "⏎" + ], + [ + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 6.25 + }, + "Space", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "☰ Menu", + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "x": 0.25 + }, + "←", + "↓", + "→", + { + "x": 0.25, + "w": 2 + }, + "0", + "," + ] +] diff --git a/internal/keyboard/layouts/da_DK.kle.json b/internal/keyboard/layouts/da_DK.kle.json new file mode 100644 index 000000000..b6b200e9d --- /dev/null +++ b/internal/keyboard/layouts/da_DK.kle.json @@ -0,0 +1,242 @@ +[ + { + "name": "Dansk da-DK (ISO 105)", + "author": "JetKVM", + "deadKeys": [ + "~", + "^", + "¨", + "`", + "´" + ], + "kbdLayoutInfo": "https://kbdlayout.info/00000406/" + }, + [ + "Esc", + { + "x": 1 + }, + "F1", + "F2", + "F3", + "F4", + { + "x": 0.5 + }, + "F5", + "F6", + "F7", + "F8", + { + "x": 0.5 + }, + "F9", + "F10", + "F11", + "F12", + { + "x": 0.25 + }, + "PrtSc", + "ScrLk", + "Pause", + { + "x": 0.25, + "d": true, + "w": 4 + }, + "Dansk\nda-DK\n\n(ISO 105)" + ], + [ + { + "y": 0.5 + }, + "§\n½", + "!\n1", + "\"\n2\n\n@", + "#\n3\n\n£", + "¤\n4\n\n$", + "%\n5\n\n€", + "&\n6", + "/\n7\n\n{", + "(\n8\n\n[", + ")\n9\n\n]", + "=\n0\n\n}", + "?\n+", + "`\n´\n\n|", + { + "w": 2 + }, + "⌫", + { + "x": 0.25 + }, + "Ins", + "Home", + "PgUp", + { + "x": 0.25 + }, + "Num", + "/", + "*", + "-" + ], + [ + { + "w": 1.5 + }, + "⭾", + "Q\nq", + "W\nw", + "E\ne\n\n€", + "R\nr", + "T\nt", + "Y\ny", + "U\nu", + "I\ni", + "O\no", + "P\np", + "Å\nå", + "^\n¨\n\n~", + { + "x": 0.25, + "w": 1.25, + "h": 2, + "w2": 1.5, + "h2": 1, + "x2": -0.25 + }, + "⏎", + { + "x": 0.25 + }, + "Del", + "End", + "PgDn", + { + "x": 0.25 + }, + "7", + "8", + "9", + { + "h": 2 + }, + "+" + ], + [ + { + "w": 1.75 + }, + "⇪", + "A\na", + "S\ns", + "D\nd", + { + "n": true + }, + "F\nf", + "G\ng", + "H\nh", + { + "n": true + }, + "J\nj", + "K\nk", + "L\nl", + "Æ\næ", + "Ø\nø", + "*\n'", + { + "x": 4.75 + }, + "4", + { + "n": true + }, + "5", + "6" + ], + [ + { + "w": 1.25 + }, + "⇧ Shift", + ">\n<\n\n\\", + "Z\nz", + "X\nx", + "C\nc", + "V\nv", + "B\nb", + "N\nn", + "M\nm\n\nµ", + ";\n,", + ":\n.", + "_\n-", + { + "w": 2.75 + }, + "⇧ Shift", + { + "x": 1.25 + }, + "↑", + { + "x": 1.25 + }, + "1", + "2", + "3", + { + "h": 2 + }, + "⏎" + ], + [ + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 6.25 + }, + "Space", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "☰ Menu", + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "x": 0.25 + }, + "←", + "↓", + "→", + { + "x": 0.25, + "w": 2 + }, + "0", + "," + ] +] diff --git a/internal/keyboard/layouts/de_CH.kle.json b/internal/keyboard/layouts/de_CH.kle.json new file mode 100644 index 000000000..7bf9fd2d4 --- /dev/null +++ b/internal/keyboard/layouts/de_CH.kle.json @@ -0,0 +1,242 @@ +[ + { + "name": "Schwiizerdütsch de-CH (ISO 105)", + "author": "JetKVM", + "deadKeys": [ + "^", + "´", + "`", + "~", + "¨" + ], + "kbdLayoutInfo": "https://kbdlayout.info/00000807/" + }, + [ + "Esc", + { + "x": 1 + }, + "F1", + "F2", + "F3", + "F4", + { + "x": 0.5 + }, + "F5", + "F6", + "F7", + "F8", + { + "x": 0.5 + }, + "F9", + "F10", + "F11", + "F12", + { + "x": 0.25 + }, + "PrtSc", + "ScrLk", + "Pause", + { + "x": 0.25, + "d": true, + "w": 4 + }, + "Schwiizerdütsch\nde-CH\n\n(ISO 105)" + ], + [ + { + "y": 0.5 + }, + "°\n§", + "+\n1\n\n¦", + "\"\n2\n\n@", + "*\n3\n\n#", + "ç\n4\n\n°", + "%\n5\n\n§", + "&\n6\n\n¬", + "/\n7\n\n|", + "(\n8\n\n¢", + ")\n9", + "=\n0", + "?\n'\n\n´", + "`\n^\n\n~", + { + "w": 2 + }, + "⌫", + { + "x": 0.25 + }, + "Ins", + "Home", + "PgUp", + { + "x": 0.25 + }, + "Num", + "/", + "*", + "-" + ], + [ + { + "w": 1.5 + }, + "⭾", + "Q\nq", + "W\nw", + "E\ne\n\n€", + "R\nr", + "T\nt", + "Z\nz", + "U\nu", + "I\ni", + "O\no", + "P\np", + "è\nü\n\n[", + "!\n¨\n\n]", + { + "x": 0.25, + "w": 1.25, + "h": 2, + "w2": 1.5, + "h2": 1, + "x2": -0.25 + }, + "⏎", + { + "x": 0.25 + }, + "Del", + "End", + "PgDn", + { + "x": 0.25 + }, + "7", + "8", + "9", + { + "h": 2 + }, + "+" + ], + [ + { + "w": 1.75 + }, + "⇪", + "A\na", + "S\ns", + "D\nd", + { + "n": true + }, + "F\nf", + "G\ng", + "H\nh", + { + "n": true + }, + "J\nj", + "K\nk", + "L\nl", + "é\nö", + "à\nä\n\n{", + "£\n$\n\n}", + { + "x": 4.75 + }, + "4", + { + "n": true + }, + "5", + "6" + ], + [ + { + "w": 1.25 + }, + "⇧ Shift", + ">\n<\n\n\\", + "Y\ny", + "X\nx", + "C\nc", + "V\nv", + "B\nb", + "N\nn", + "M\nm", + ";\n,", + ":\n.", + "_\n-", + { + "w": 2.75 + }, + "⇧ Shift", + { + "x": 1.25 + }, + "↑", + { + "x": 1.25 + }, + "1", + "2", + "3", + { + "h": 2 + }, + "⏎" + ], + [ + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 6.25 + }, + "Space", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "☰ Menu", + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "x": 0.25 + }, + "←", + "↓", + "→", + { + "x": 0.25, + "w": 2 + }, + "0", + "." + ] +] diff --git a/internal/keyboard/layouts/de_DE.kle.json b/internal/keyboard/layouts/de_DE.kle.json new file mode 100644 index 000000000..4c30560a5 --- /dev/null +++ b/internal/keyboard/layouts/de_DE.kle.json @@ -0,0 +1,240 @@ +[ + { + "name": "Deutsch de-DE (ISO 105)", + "author": "JetKVM", + "deadKeys": [ + "^", + "´", + "`" + ], + "kbdLayoutInfo": "https://kbdlayout.info/00000407/" + }, + [ + "Esc", + { + "x": 1 + }, + "F1", + "F2", + "F3", + "F4", + { + "x": 0.5 + }, + "F5", + "F6", + "F7", + "F8", + { + "x": 0.5 + }, + "F9", + "F10", + "F11", + "F12", + { + "x": 0.25 + }, + "PrtSc", + "ScrLk", + "Pause", + { + "x": 0.25, + "d": true, + "w": 4 + }, + "Deutsch\nde-DE\n\n(ISO 105)" + ], + [ + { + "y": 0.5 + }, + "°\n^", + "!\n1", + "\"\n2\n\n²", + "§\n3\n\n³", + "$\n4", + "%\n5", + "&\n6", + "/\n7\n\n{", + "(\n8\n\n[", + ")\n9\n\n]", + "=\n0\n\n}", + "?\nß\nẞ\n\\", + "`\n´", + { + "w": 2 + }, + "⌫", + { + "x": 0.25 + }, + "Ins", + "Home", + "PgUp", + { + "x": 0.25 + }, + "Num", + "/", + "*", + "-" + ], + [ + { + "w": 1.5 + }, + "⭾", + "Q\nq\n\n@", + "W\nw", + "E\ne\n\n€", + "R\nr", + "T\nt", + "Z\nz", + "U\nu", + "I\ni", + "O\no", + "P\np", + "Ü\nü", + "*\n+\n\n~", + { + "x": 0.25, + "w": 1.25, + "h": 2, + "w2": 1.5, + "h2": 1, + "x2": -0.25 + }, + "⏎", + { + "x": 0.25 + }, + "Del", + "End", + "PgDn", + { + "x": 0.25 + }, + "7", + "8", + "9", + { + "h": 2 + }, + "+" + ], + [ + { + "w": 1.75 + }, + "⇪", + "A\na", + "S\ns", + "D\nd", + { + "n": true + }, + "F\nf", + "G\ng", + "H\nh", + { + "n": true + }, + "J\nj", + "K\nk", + "L\nl", + "Ö\nö", + "Ä\nä", + "'\n#", + { + "x": 4.75 + }, + "4", + { + "n": true + }, + "5", + "6" + ], + [ + { + "w": 1.25 + }, + "⇧ Shift", + ">\n<\n\n|", + "Y\ny", + "X\nx", + "C\nc", + "V\nv", + "B\nb", + "N\nn", + "M\nm\n\nµ", + ";\n,", + ":\n.", + "_\n-", + { + "w": 2.75 + }, + "⇧ Shift", + { + "x": 1.25 + }, + "↑", + { + "x": 1.25 + }, + "1", + "2", + "3", + { + "h": 2 + }, + "⏎" + ], + [ + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 6.25 + }, + "Space", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "☰ Menu", + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "x": 0.25 + }, + "←", + "↓", + "→", + { + "x": 0.25, + "w": 2 + }, + "0", + "," + ] +] diff --git a/internal/keyboard/layouts/en_UK.kle.json b/internal/keyboard/layouts/en_UK.kle.json new file mode 100644 index 000000000..263c5de01 --- /dev/null +++ b/internal/keyboard/layouts/en_UK.kle.json @@ -0,0 +1,235 @@ +[ + { + "name": "English (UK) en-UK (ISO 105)", + "author": "JetKVM", + "kbdLayoutInfo": "https://kbdlayout.info/00000809/" + }, + [ + "Esc", + { + "x": 1 + }, + "F1", + "F2", + "F3", + "F4", + { + "x": 0.5 + }, + "F5", + "F6", + "F7", + "F8", + { + "x": 0.5 + }, + "F9", + "F10", + "F11", + "F12", + { + "x": 0.25 + }, + "PrtSc", + "ScrLk", + "Pause", + { + "x": 0.25, + "d": true, + "w": 4 + }, + "English (UK)\nen-UK\n\n(ISO 105)" + ], + [ + { + "y": 0.5 + }, + "¬\n`\n\n¦", + "!\n1", + "\"\n2", + "£\n3", + "$\n4\n\n€", + "%\n5", + "^\n6", + "&\n7", + "*\n8", + "(\n9", + ")\n0", + "_\n-", + "+\n=", + { + "w": 2 + }, + "⌫", + { + "x": 0.25 + }, + "Ins", + "Home", + "PgUp", + { + "x": 0.25 + }, + "Num", + "/", + "*", + "-" + ], + [ + { + "w": 1.5 + }, + "⭾", + "Q\nq", + "W\nw", + "E\ne\nÉ\né", + "R\nr", + "T\nt", + "Y\ny", + "U\nu\nÚ\nú", + "I\ni\nÍ\ní", + "O\no\nÓ\nó", + "P\np", + "{\n[", + "}\n]", + { + "x": 0.25, + "w": 1.25, + "h": 2, + "w2": 1.5, + "h2": 1, + "x2": -0.25 + }, + "⏎", + { + "x": 0.25 + }, + "Del", + "End", + "PgDn", + { + "x": 0.25 + }, + "7", + "8", + "9", + { + "h": 2 + }, + "+" + ], + [ + { + "w": 1.75 + }, + "⇪", + "A\na\nÁ\ná", + "S\ns", + "D\nd", + { + "n": true + }, + "F\nf", + "G\ng", + "H\nh", + { + "n": true + }, + "J\nj", + "K\nk", + "L\nl", + ":\n;", + "@\n'", + "~\n#\n|\n\\", + { + "x": 4.75 + }, + "4", + { + "n": true + }, + "5", + "6" + ], + [ + { + "w": 1.25 + }, + "⇧ Shift", + "|\n\\", + "Z\nz", + "X\nx", + "C\nc", + "V\nv", + "B\nb", + "N\nn", + "M\nm", + "<\n,", + ">\n.", + "?\n/", + { + "w": 2.75 + }, + "⇧ Shift", + { + "x": 1.25 + }, + "↑", + { + "x": 1.25 + }, + "1", + "2", + "3", + { + "h": 2 + }, + "⏎" + ], + [ + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 6.25 + }, + "Space", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "☰ Menu", + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "x": 0.25 + }, + "←", + "↓", + "→", + { + "x": 0.25, + "w": 2 + }, + "0", + "." + ] +] diff --git a/internal/keyboard/layouts/en_US.kle.json b/internal/keyboard/layouts/en_US.kle.json new file mode 100644 index 000000000..1cbb7af08 --- /dev/null +++ b/internal/keyboard/layouts/en_US.kle.json @@ -0,0 +1,232 @@ +[ + { + "name": "English (US) en-US (ANSI 104)", + "author": "JetKVM", + "kbdLayoutInfo": "https://kbdlayout.info/00000409/" + }, + [ + "Esc", + { + "x": 1 + }, + "F1", + "F2", + "F3", + "F4", + { + "x": 0.5 + }, + "F5", + "F6", + "F7", + "F8", + { + "x": 0.5 + }, + "F9", + "F10", + "F11", + "F12", + { + "x": 0.25 + }, + "PrtSc", + "ScrLk", + "Pause", + { + "x": 0.25, + "d": true, + "w": 4 + }, + "English (US)\nen-US\n\n(ANSI 104)" + ], + [ + { + "y": 0.5 + }, + "~\n`", + "!\n1", + "@\n2", + "#\n3", + "$\n4", + "%\n5", + "^\n6", + "&\n7", + "*\n8", + "(\n9", + ")\n0", + "_\n-", + "+\n=", + { + "w": 2 + }, + "⌫", + { + "x": 0.25 + }, + "Ins", + "Home", + "PgUp", + { + "x": 0.25 + }, + "Num", + "/", + "*", + "-" + ], + [ + { + "w": 1.5 + }, + "⭾", + "Q\nq", + "W\nw", + "E\ne", + "R\nr", + "T\nt", + "Y\ny", + "U\nu", + "I\ni", + "O\no", + "P\np", + "{\n[", + "}\n]", + { + "w": 1.5 + }, + "|\n\\", + { + "x": 0.25 + }, + "Del", + "End", + "PgDn", + { + "x": 0.25 + }, + "7", + "8", + "9", + { + "h": 2 + }, + "+" + ], + [ + { + "w": 1.75 + }, + "⇪", + "A\na", + "S\ns", + "D\nd", + { + "n": true + }, + "F\nf", + "G\ng", + "H\nh", + { + "n": true + }, + "J\nj", + "K\nk", + "L\nl", + ":\n;", + "\"\n'", + { + "w": 2.25 + }, + "⏎", + { + "x": 3.5 + }, + "4", + { + "n": true + }, + "5", + "6" + ], + [ + { + "w": 2.25 + }, + "⇧ Shift", + "Z\nz", + "X\nx", + "C\nc", + "V\nv", + "B\nb", + "N\nn", + "M\nm", + "<\n,", + ">\n.", + "?\n/", + { + "w": 2.75 + }, + "⇧ Shift", + { + "x": 1.25 + }, + "↑", + { + "x": 1.25 + }, + "1", + "2", + "3", + { + "h": 2 + }, + "⏎" + ], + [ + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 6.25 + }, + "Space", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "☰ Menu", + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "x": 0.25 + }, + "←", + "↓", + "→", + { + "x": 0.25, + "w": 2 + }, + "0", + "." + ] +] diff --git a/internal/keyboard/layouts/es_ES.kle.json b/internal/keyboard/layouts/es_ES.kle.json new file mode 100644 index 000000000..4063ac158 --- /dev/null +++ b/internal/keyboard/layouts/es_ES.kle.json @@ -0,0 +1,241 @@ +[ + { + "name": "Español es-ES (ISO 105)", + "author": "JetKVM", + "deadKeys": [ + "´", + "^", + "`", + "¨" + ], + "kbdLayoutInfo": "https://kbdlayout.info/0000040A/" + }, + [ + "Esc", + { + "x": 1 + }, + "F1", + "F2", + "F3", + "F4", + { + "x": 0.5 + }, + "F5", + "F6", + "F7", + "F8", + { + "x": 0.5 + }, + "F9", + "F10", + "F11", + "F12", + { + "x": 0.25 + }, + "PrtSc", + "ScrLk", + "Pause", + { + "x": 0.25, + "d": true, + "w": 4 + }, + "Español\nes-ES\n\n(ISO 105)" + ], + [ + { + "y": 0.5 + }, + "ª\nº\n\n\\", + "!\n1\n\n|", + "\"\n2\n\n@", + "·\n3\n\n#", + "$\n4\n\n~", + "%\n5\n\n€", + "&\n6\n\n¬", + "/\n7", + "(\n8", + ")\n9", + "=\n0", + "?\n'", + "¿\n¡", + { + "w": 2 + }, + "⌫", + { + "x": 0.25 + }, + "Ins", + "Home", + "PgUp", + { + "x": 0.25 + }, + "Num", + "/", + "*", + "-" + ], + [ + { + "w": 1.5 + }, + "⭾", + "Q\nq", + "W\nw", + "E\ne\n\n€", + "R\nr", + "T\nt", + "Y\ny", + "U\nu", + "I\ni", + "O\no", + "P\np", + "^\n`\n\n[", + "*\n+\n\n]", + { + "x": 0.25, + "w": 1.25, + "h": 2, + "w2": 1.5, + "h2": 1, + "x2": -0.25 + }, + "⏎", + { + "x": 0.25 + }, + "Del", + "End", + "PgDn", + { + "x": 0.25 + }, + "7", + "8", + "9", + { + "h": 2 + }, + "+" + ], + [ + { + "w": 1.75 + }, + "⇪", + "A\na", + "S\ns", + "D\nd", + { + "n": true + }, + "F\nf", + "G\ng", + "H\nh", + { + "n": true + }, + "J\nj", + "K\nk", + "L\nl", + "Ñ\nñ", + "¨\n´\n\n{", + "Ç\nç\n\n}", + { + "x": 4.75 + }, + "4", + { + "n": true + }, + "5", + "6" + ], + [ + { + "w": 1.25 + }, + "⇧ Shift", + ">\n<", + "Z\nz", + "X\nx", + "C\nc", + "V\nv", + "B\nb", + "N\nn", + "M\nm", + ";\n,", + ":\n.", + "_\n-", + { + "w": 2.75 + }, + "⇧ Shift", + { + "x": 1.25 + }, + "↑", + { + "x": 1.25 + }, + "1", + "2", + "3", + { + "h": 2 + }, + "⏎" + ], + [ + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 6.25 + }, + "Space", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "☰ Menu", + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "x": 0.25 + }, + "←", + "↓", + "→", + { + "x": 0.25, + "w": 2 + }, + "0", + "." + ] +] diff --git a/internal/keyboard/layouts/fr_BE.kle.json b/internal/keyboard/layouts/fr_BE.kle.json new file mode 100644 index 000000000..255cfa126 --- /dev/null +++ b/internal/keyboard/layouts/fr_BE.kle.json @@ -0,0 +1,242 @@ +[ + { + "name": "Belgisch Nederlands fr-BE (ISO 105)", + "author": "JetKVM", + "deadKeys": [ + "^", + "¨", + "´", + "`", + "~" + ], + "kbdLayoutInfo": "https://kbdlayout.info/0000080C/" + }, + [ + "Esc", + { + "x": 1 + }, + "F1", + "F2", + "F3", + "F4", + { + "x": 0.5 + }, + "F5", + "F6", + "F7", + "F8", + { + "x": 0.5 + }, + "F9", + "F10", + "F11", + "F12", + { + "x": 0.25 + }, + "PrtSc", + "ScrLk", + "Pause", + { + "x": 0.25, + "d": true, + "w": 4 + }, + "Belgisch Nederlands fr-BE (ISO 105)\nfr-BE\nnl-BE\n(ISO 105)" + ], + [ + { + "y": 0.5 + }, + "³\n²", + "1\n&\n\n|", + "2\né\n\n@", + "3\n\"\n\n#", + "4\n'\n\n{", + "5\n(\n\n[", + "6\n§\n\n^", + "7\nè", + "8\n!", + "9\nç\n\n{", + "0\nà\n\n}", + "°\n)", + "_\n-", + { + "w": 2 + }, + "⌫", + { + "x": 0.25 + }, + "Ins", + "Home", + "PgUp", + { + "x": 0.25 + }, + "Num", + "/", + "*", + "-" + ], + [ + { + "w": 1.5 + }, + "⭾", + "A\na", + "Z\nz", + "E\ne\n\n€", + "R\nr", + "T\nt", + "Y\ny", + "U\nu", + "I\ni", + "O\no", + "P\np", + "¨\n^\n\n[", + "*\n$\n\n]", + { + "x": 0.25, + "w": 1.25, + "h": 2, + "w2": 1.5, + "h2": 1, + "x2": -0.25 + }, + "⏎", + { + "x": 0.25 + }, + "Del", + "End", + "PgDn", + { + "x": 0.25 + }, + "7", + "8", + "9", + { + "h": 2 + }, + "+" + ], + [ + { + "w": 1.75 + }, + "⇪", + "Q\nq", + "S\ns", + "D\nd", + { + "n": true + }, + "F\nf", + "G\ng", + "H\nh", + { + "n": true + }, + "J\nj", + "K\nk", + "L\nl", + "M\nm", + "%\nù\n´\n´", + "£\nµ\n`\n`", + { + "x": 4.75 + }, + "4", + { + "n": true + }, + "5", + "6" + ], + [ + { + "w": 1.25 + }, + "⇧ Shift", + ">\n<\n\n\\", + "W\nw", + "X\nx", + "C\nc", + "V\nv", + "B\nb", + "N\nn", + "?\n,", + ".\n;", + "/\n:", + "+\n=\n~\n~", + { + "w": 2.75 + }, + "⇧ Shift", + { + "x": 1.25 + }, + "↑", + { + "x": 1.25 + }, + "1", + "2", + "3", + { + "h": 2 + }, + "⏎" + ], + [ + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 6.25 + }, + "Space", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "☰ Menu", + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "x": 0.25 + }, + "←", + "↓", + "→", + { + "x": 0.25, + "w": 2 + }, + "0", + "." + ] +] diff --git a/internal/keyboard/layouts/fr_CH.kle.json b/internal/keyboard/layouts/fr_CH.kle.json new file mode 100644 index 000000000..492b04604 --- /dev/null +++ b/internal/keyboard/layouts/fr_CH.kle.json @@ -0,0 +1,242 @@ +[ + { + "name": "Français (Suisse) fr-CH (ISO 105)", + "author": "JetKVM", + "deadKeys": [ + "^", + "´", + "`", + "~", + "¨" + ], + "kbdLayoutInfo": "https://kbdlayout.info/0000100C/" + }, + [ + "Esc", + { + "x": 1 + }, + "F1", + "F2", + "F3", + "F4", + { + "x": 0.5 + }, + "F5", + "F6", + "F7", + "F8", + { + "x": 0.5 + }, + "F9", + "F10", + "F11", + "F12", + { + "x": 0.25 + }, + "PrtSc", + "ScrLk", + "Pause", + { + "x": 0.25, + "d": true, + "w": 4 + }, + "Français (Suisse)\nfr-CH\n\n(ISO 105)" + ], + [ + { + "y": 0.5 + }, + "°\n§", + "+\n1\n\n¦", + "\"\n2\n\n@", + "*\n3\n\n#", + "ç\n4\n\n°", + "%\n5\n\n§", + "&\n6\n\n¬", + "/\n7\n\n|", + "(\n8\n\n¢", + ")\n9", + "=\n0", + "?\n'\n\n´", + "`\n^\n\n~", + { + "w": 2 + }, + "⌫", + { + "x": 0.25 + }, + "Ins", + "Home", + "PgUp", + { + "x": 0.25 + }, + "Num", + "/", + "*", + "-" + ], + [ + { + "w": 1.5 + }, + "⭾", + "Q\nq", + "W\nw", + "E\ne\n\n€", + "R\nr", + "T\nt", + "Z\nz", + "U\nu", + "I\ni", + "O\no", + "P\np", + "ü\nè\n\n[", + "!\n¨\n\n]", + { + "x": 0.25, + "w": 1.25, + "h": 2, + "w2": 1.5, + "h2": 1, + "x2": -0.25 + }, + "⏎", + { + "x": 0.25 + }, + "Del", + "End", + "PgDn", + { + "x": 0.25 + }, + "7", + "8", + "9", + { + "h": 2 + }, + "+" + ], + [ + { + "w": 1.75 + }, + "⇪", + "A\na", + "S\ns", + "D\nd", + { + "n": true + }, + "F\nf", + "G\ng", + "H\nh", + { + "n": true + }, + "J\nj", + "K\nk", + "L\nl", + "ö\né", + "ä\nà\n\n{", + "£\n$\n\n}", + { + "x": 4.75 + }, + "4", + { + "n": true + }, + "5", + "6" + ], + [ + { + "w": 1.25 + }, + "⇧ Shift", + ">\n<\n\n\\", + "Y\ny", + "X\nx", + "C\nc", + "V\nv", + "B\nb", + "N\nn", + "M\nm", + ";\n,", + ":\n.", + "_\n-", + { + "w": 2.75 + }, + "⇧ Shift", + { + "x": 1.25 + }, + "↑", + { + "x": 1.25 + }, + "1", + "2", + "3", + { + "h": 2 + }, + "⏎" + ], + [ + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 6.25 + }, + "Space", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "☰ Menu", + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "x": 0.25 + }, + "←", + "↓", + "→", + { + "x": 0.25, + "w": 2 + }, + "0", + "." + ] +] diff --git a/internal/keyboard/layouts/fr_FR.kle.json b/internal/keyboard/layouts/fr_FR.kle.json new file mode 100644 index 000000000..a3aba3fda --- /dev/null +++ b/internal/keyboard/layouts/fr_FR.kle.json @@ -0,0 +1,239 @@ +[ + { + "name": "Français fr-FR (ISO 105)", + "author": "JetKVM", + "deadKeys": [ + "^", + "¨" + ], + "kbdLayoutInfo": "https://kbdlayout.info/0000040C/" + }, + [ + "Esc", + { + "x": 1 + }, + "F1", + "F2", + "F3", + "F4", + { + "x": 0.5 + }, + "F5", + "F6", + "F7", + "F8", + { + "x": 0.5 + }, + "F9", + "F10", + "F11", + "F12", + { + "x": 0.25 + }, + "PrtSc", + "ScrLk", + "Pause", + { + "x": 0.25, + "d": true, + "w": 4 + }, + "Français\nfr-FR\n\n(ISO 105)" + ], + [ + { + "y": 0.5 + }, + "²", + "1\n&", + "2\né\n\n~", + "3\n\"\n\n#", + "4\n'\n\n{", + "5\n(\n\n[", + "6\n-\n\n|", + "7\nè\n\n`", + "8\n_\n\n\\", + "9\nç\n\n^", + "0\nà\n\n@", + "°\n)\n\n]", + "+\n=\n\n}", + { + "w": 2 + }, + "⌫", + { + "x": 0.25 + }, + "Ins", + "Home", + "PgUp", + { + "x": 0.25 + }, + "Num", + "/", + "*", + "-" + ], + [ + { + "w": 1.5 + }, + "⭾", + "A\na", + "Z\nz", + "E\ne\n\n€", + "R\nr", + "T\nt", + "Y\ny", + "U\nu", + "I\ni", + "O\no", + "P\np", + "¨\n^", + "£\n$\n\n¤", + { + "x": 0.25, + "w": 1.25, + "h": 2, + "w2": 1.5, + "h2": 1, + "x2": -0.25 + }, + "⏎", + { + "x": 0.25 + }, + "Del", + "End", + "PgDn", + { + "x": 0.25 + }, + "7", + "8", + "9", + { + "h": 2 + }, + "+" + ], + [ + { + "w": 1.75 + }, + "⇪", + "Q\nq", + "S\ns", + "D\nd", + { + "n": true + }, + "F\nf", + "G\ng", + "H\nh", + { + "n": true + }, + "J\nj", + "K\nk", + "L\nl", + "M\nm", + "%\nù", + "µ\n*", + { + "x": 4.75 + }, + "4", + { + "n": true + }, + "5", + "6" + ], + [ + { + "w": 1.25 + }, + "⇧ Shift", + ">\n<", + "W\nw", + "X\nx", + "C\nc", + "V\nv", + "B\nb", + "N\nn", + "?\n,", + ".\n;", + "/\n:", + "§\n!", + { + "w": 2.75 + }, + "⇧ Shift", + { + "x": 1.25 + }, + "↑", + { + "x": 1.25 + }, + "1", + "2", + "3", + { + "h": 2 + }, + "⏎" + ], + [ + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 6.25 + }, + "Space", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "☰ Menu", + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "x": 0.25 + }, + "←", + "↓", + "→", + { + "x": 0.25, + "w": 2 + }, + "0", + "." + ] +] diff --git a/internal/keyboard/layouts/hu_HU.kle.json b/internal/keyboard/layouts/hu_HU.kle.json new file mode 100644 index 000000000..b0006e813 --- /dev/null +++ b/internal/keyboard/layouts/hu_HU.kle.json @@ -0,0 +1,240 @@ +[ + { + "name": "Magyar hu-HU (ISO 105)", + "author": "JetKVM", + "deadKeys": [ + "´", + "˝", + "¨" + ], + "kbdLayoutInfo": "https://kbdlayout.info/0000040E/" + }, + [ + "Esc", + { + "x": 1 + }, + "F1", + "F2", + "F3", + "F4", + { + "x": 0.5 + }, + "F5", + "F6", + "F7", + "F8", + { + "x": 0.5 + }, + "F9", + "F10", + "F11", + "F12", + { + "x": 0.25 + }, + "PrtSc", + "ScrLk", + "Pause", + { + "x": 0.25, + "d": true, + "w": 4 + }, + "Magyar\nhu-HU\n\n(ISO 105)" + ], + [ + { + "y": 0.5 + }, + "§\n0", + "'\n1\n\n~", + "\"\n2\n\nˇ", + "+\n3\n\n^", + "!\n4\n\n˘", + "%\n5\n\n°", + "/\n6\n\n˛", + "=\n7\n\n`", + "(\n8\n\n˙", + ")\n9\n\n´", + "Ö\nö\n\n˝", + "Ü\nü\n\n¨", + "Ó\nó\n\n¸", + { + "w": 2 + }, + "⌫", + { + "x": 0.25 + }, + "Ins", + "Home", + "PgUp", + { + "x": 0.25 + }, + "Num", + "/", + "*", + "-" + ], + [ + { + "w": 1.5 + }, + "⭾", + "Q\nq\n\n\\", + "W\nw\n\n|", + "E\ne\n\nÄ", + "R\nr", + "T\nt", + "Z\nz", + "U\nu\n\n€", + "I\ni\n\nÍ", + "O\no", + "P\np", + "Ő\nő\n\n÷", + "Ú\nú\n\n×", + { + "x": 0.25, + "w": 1.25, + "h": 2, + "w2": 1.5, + "h2": 1, + "x2": -0.25 + }, + "⏎", + { + "x": 0.25 + }, + "Del", + "End", + "PgDn", + { + "x": 0.25 + }, + "7", + "8", + "9", + { + "h": 2 + }, + "+" + ], + [ + { + "w": 1.75 + }, + "⇪", + "A\na\n\nä", + "S\ns\n\nđ", + "D\nd\n\nĐ", + { + "n": true + }, + "F\nf\n\n[", + "G\ng\n\n]", + "H\nh", + { + "n": true + }, + "J\nj\n\ní", + "K\nk\n\nł", + "L\nl\n\nŁ", + "É\né\n\n$", + "Á\ná\n\nß", + "Ű\nű\n\n¤", + { + "x": 4.75 + }, + "4", + { + "n": true + }, + "5", + "6" + ], + [ + { + "w": 1.25 + }, + "⇧ Shift", + "Í\ní\n\n<", + "Y\ny\n\n>", + "X\nx\n\n#", + "C\nc\n\n&", + "V\nv\n\n@", + "B\nb\n\n{", + "N\nn\n\n}", + "M\nm\n\n<", + "?\n,\n\n;", + ":\n.\n\n>", + "_\n-\n\n*", + { + "w": 2.75 + }, + "⇧ Shift", + { + "x": 1.25 + }, + "↑", + { + "x": 1.25 + }, + "1", + "2", + "3", + { + "h": 2 + }, + "⏎" + ], + [ + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 6.25 + }, + "Space", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "☰ Menu", + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "x": 0.25 + }, + "←", + "↓", + "→", + { + "x": 0.25, + "w": 2 + }, + "0", + "," + ] +] diff --git a/internal/keyboard/layouts/it_IT.kle.json b/internal/keyboard/layouts/it_IT.kle.json new file mode 100644 index 000000000..5f9dc6aef --- /dev/null +++ b/internal/keyboard/layouts/it_IT.kle.json @@ -0,0 +1,235 @@ +[ + { + "name": "Italiano it-IT (ISO 105)", + "author": "JetKVM", + "kbdLayoutInfo": "https://kbdlayout.info/00000410/" + }, + [ + "Esc", + { + "x": 1 + }, + "F1", + "F2", + "F3", + "F4", + { + "x": 0.5 + }, + "F5", + "F6", + "F7", + "F8", + { + "x": 0.5 + }, + "F9", + "F10", + "F11", + "F12", + { + "x": 0.25 + }, + "PrtSc", + "ScrLk", + "Pause", + { + "x": 0.25, + "d": true, + "w": 4 + }, + "Italiano\nit-IT\n\n(ISO 105)" + ], + [ + { + "y": 0.5 + }, + "|\n\\", + "!\n1", + "\"\n2", + "£\n3", + "$\n4", + "%\n5\n\n€", + "&\n6", + "/\n7", + "(\n8", + ")\n9", + "=\n0", + "?\n'", + "^\nì", + { + "w": 2 + }, + "⌫", + { + "x": 0.25 + }, + "Ins", + "Home", + "PgUp", + { + "x": 0.25 + }, + "Num", + "/", + "*", + "-" + ], + [ + { + "w": 1.5 + }, + "⭾", + "Q\nq", + "W\nw", + "E\ne\n\n€", + "R\nr", + "T\nt", + "Y\ny", + "U\nu", + "I\ni", + "O\no", + "P\np", + "é\nè\n{\n[", + "*\n+\n}\n]", + { + "x": 0.25, + "w": 1.25, + "h": 2, + "w2": 1.5, + "h2": 1, + "x2": -0.25 + }, + "⏎", + { + "x": 0.25 + }, + "Del", + "End", + "PgDn", + { + "x": 0.25 + }, + "7", + "8", + "9", + { + "h": 2 + }, + "+" + ], + [ + { + "w": 1.75 + }, + "⇪", + "A\na", + "S\ns", + "D\nd", + { + "n": true + }, + "F\nf", + "G\ng", + "H\nh", + { + "n": true + }, + "J\nj", + "K\nk", + "L\nl", + "ç\nò\n\n@", + "°\nà\n\n#", + "§\nù", + { + "x": 4.75 + }, + "4", + { + "n": true + }, + "5", + "6" + ], + [ + { + "w": 1.25 + }, + "⇧ Shift", + ">\n<", + "Z\nz", + "X\nx", + "C\nc", + "V\nv", + "B\nb", + "N\nn", + "M\nm", + ";\n,", + ":\n.", + "_\n-", + { + "w": 2.75 + }, + "⇧ Shift", + { + "x": 1.25 + }, + "↑", + { + "x": 1.25 + }, + "1", + "2", + "3", + { + "h": 2 + }, + "⏎" + ], + [ + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 6.25 + }, + "Space", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "☰ Menu", + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "x": 0.25 + }, + "←", + "↓", + "→", + { + "x": 0.25, + "w": 2 + }, + "0", + "." + ] +] diff --git a/internal/keyboard/layouts/ja_JP.kle.json b/internal/keyboard/layouts/ja_JP.kle.json new file mode 100644 index 000000000..ade7f4a5e --- /dev/null +++ b/internal/keyboard/layouts/ja_JP.kle.json @@ -0,0 +1,220 @@ +[ + { + "name": "Japanese ja-JP (JIS 109)", + "author": "JetKVM", + "kbdLayoutInfo": "https://kbdlayout.info/00000411/" + }, + [ + "␛", + { + "x": 1 + }, + "F1", + "F2", + "F3", + "F4", + { + "x": 0.5 + }, + "F5", + "F6", + "F7", + "F8", + { + "x": 0.5 + }, + "F9", + "F10", + "F11", + "F12", + { + "x": 0.25 + }, + "PrtSc", + "Scroll Lock", + "Pause" + ], + [ + { + "y": 0.5 + }, + "~\n`\n\n\n\n\n\n\nロ\n\nロ", + "!\n1\n\n\n\n\n\n\nヌ\n\nヌ", + "@\n2\n\n\n\n\n\n\nフ\n\nフ", + "#\n3\n\n\n\n\n\n\nァ\n\nア", + "$\n4\n\n\n\n\n\n\nゥ\n\nウ", + "%\n5\n\n\n\n\n\n\nェ\n\nエ", + "^\n6\n\n\n\n\n\n\nォ\n\nオ", + "&\n7\n\n\n\n\n\n\nャ\n\nヤ", + "*\n8\n\n\n\n\n\n\nュ\n\nユ", + "(\n9\n\n\n\n\n\n\nョ\n\nヨ", + ")\n0\n\n\n\n\n\n\nヲ\n\nワ", + "_\n-\n\n\n\n\n\n\nー\n\nホ", + "+\n=\n\n\n\n\n\n\nヘ\n\nヘ", + { + "w": 2 + }, + "␈\n␈\n\n\n\n\n\n\n␈\n\n␈", + { + "x": 0.25 + }, + "Insert", + "Home", + "Page Up", + { + "x": 0.25 + }, + "Num Lock", + "/", + "*", + "-" + ], + [ + { + "w": 1.5 + }, + "␉\n␉\n\n\n\n\n\n\n␉\n\n␉", + "Q\nq\n\n\n\n\n\n\nタ\n\nタ", + "W\nw\n\n\n\n\n\n\nテ\n\nテ", + "E\ne\n\n\n\n\n\n\nィ\n\nイ", + "R\nr\n\n\n\n\n\n\nス\n\nス", + "T\nt\n\n\n\n\n\n\nカ\n\nカ", + "Y\ny\n\n\n\n\n\n\nン\n\nン", + "U\nu\n\n\n\n\n\n\nナ\n\nナ", + "I\ni\n\n\n\n\n\n\nニ\n\nニ", + "O\no\n\n\n\n\n\n\nラ\n\nラ", + "P\np\n\n\n\n\n\n\nセ\n\nセ", + "{\n[\n\n\n\n\n\n\n「\n\n゙", + "}\n]\n\n\n\n\n\n\n」\n\n゚", + { + "x": 0.25, + "w": 1.25, + "h": 2, + "w2": 1.5, + "h2": 1, + "x2": -0.25 + }, + "␍", + { + "x": 0.25 + }, + "Delete", + "End", + "Page Down", + { + "x": 0.25 + }, + "7", + "8", + "9", + { + "h": 2 + }, + "+" + ], + [ + { + "w": 1.75 + }, + "Caps Lock", + "A\na\n\n\n\n\n\n\nチ\n\nチ", + "S\ns\n\n\n\n\n\n\nト\n\nト", + "D\nd\n\n\n\n\n\n\nシ\n\nシ", + "F\nf\n\n\n\n\n\n\nハ\n\nハ", + "G\ng\n\n\n\n\n\n\nキ\n\nキ", + "H\nh\n\n\n\n\n\n\nク\n\nク", + "J\nj\n\n\n\n\n\n\nマ\n\nマ", + "K\nk\n\n\n\n\n\n\nノ\n\nノ", + "L\nl\n\n\n\n\n\n\nリ\n\nリ", + ":\n;\n\n\n\n\n\n\nレ\n\nレ", + "\"\n'\n\n\n\n\n\n\nケ\n\nケ", + "|\n\\\n\n\n\n\n\n\nム\n\nム", + { + "x": 4.75 + }, + "4", + "5", + "6" + ], + [ + { + "w": 1.25 + }, + "Shift", + "|\n\\\n\n\n\n\n\n\nム\n\nム", + "Z\nz\n\n\n\n\n\n\nッ\n\nツ", + "X\nx\n\n\n\n\n\n\nサ\n\nサ", + "C\nc\n\n\n\n\n\n\nソ\n\nソ", + "V\nv\n\n\n\n\n\n\nヒ\n\nヒ", + "B\nb\n\n\n\n\n\n\nコ\n\nコ", + "N\nn\n\n\n\n\n\n\nミ\n\nミ", + "M\nm\n\n\n\n\n\n\nモ\n\nモ", + "<\n,\n\n\n\n\n\n\n、\n\nネ", + ">\n.\n\n\n\n\n\n\n。\n\nル", + "?\n/\n\n\n\n\n\n\n・\n\nメ", + { + "w": 2.75 + }, + "Shift", + { + "x": 1.25 + }, + "↑", + { + "x": 1.25 + }, + "1", + "2", + "3", + { + "h": 2 + }, + "␍" + ], + [ + { + "w": 1.25 + }, + "Ctrl", + { + "w": 1.25 + }, + "Win", + { + "w": 1.25 + }, + "Alt", + { + "w": 6.25 + }, + "␠\n␠\n\n\n\n\n\n\n␠\n\n␠", + { + "w": 1.25 + }, + "AltGr", + { + "w": 1.25 + }, + "Win", + { + "w": 1.25 + }, + "Menu", + { + "w": 1.25 + }, + "Ctrl", + { + "x": 0.25 + }, + "←", + "↓", + "→", + { + "x": 0.25, + "w": 2 + }, + "0", + "." + ] +] diff --git a/internal/keyboard/layouts/nb_NO.kle.json b/internal/keyboard/layouts/nb_NO.kle.json new file mode 100644 index 000000000..572dd324d --- /dev/null +++ b/internal/keyboard/layouts/nb_NO.kle.json @@ -0,0 +1,242 @@ +[ + { + "name": "Norsk bokmål nb-NO (ISO 105)", + "author": "JetKVM", + "deadKeys": [ + "¨", + "´", + "^", + "`", + "~" + ], + "kbdLayoutInfo": "https://kbdlayout.info/00000414/" + }, + [ + "Esc", + { + "x": 1 + }, + "F1", + "F2", + "F3", + "F4", + { + "x": 0.5 + }, + "F5", + "F6", + "F7", + "F8", + { + "x": 0.5 + }, + "F9", + "F10", + "F11", + "F12", + { + "x": 0.25 + }, + "PrtSc", + "ScrLk", + "Pause", + { + "x": 0.25, + "d": true, + "w": 4 + }, + "Norsk bokmål\nnb-NO\n\n(ISO 105)" + ], + [ + { + "y": 0.5 + }, + "§\n|", + "!\n1", + "\"\n2\n\n@", + "#\n3\n\n£", + "¤\n4\n\n$", + "%\n5\n\n€", + "&\n6", + "/\n7\n\n{", + "(\n8\n\n[", + ")\n9\n\n]", + "=\n0\n\n}", + "?\n+", + "`\n\\\n\n´", + { + "w": 2 + }, + "⌫", + { + "x": 0.25 + }, + "Ins", + "Home", + "PgUp", + { + "x": 0.25 + }, + "Num", + "/", + "*", + "-" + ], + [ + { + "w": 1.5 + }, + "⭾", + "Q\nq", + "W\nw", + "E\ne\n\n€", + "R\nr", + "T\nt", + "Y\ny", + "U\nu", + "I\ni", + "O\no", + "P\np", + "Å\nå", + "^\n¨\n\n~", + { + "x": 0.25, + "w": 1.25, + "h": 2, + "w2": 1.5, + "h2": 1, + "x2": -0.25 + }, + "⏎", + { + "x": 0.25 + }, + "Del", + "End", + "PgDn", + { + "x": 0.25 + }, + "7", + "8", + "9", + { + "h": 2 + }, + "+" + ], + [ + { + "w": 1.75 + }, + "⇪", + "A\na", + "S\ns", + "D\nd", + { + "n": true + }, + "F\nf", + "G\ng", + "H\nh", + { + "n": true + }, + "J\nj", + "K\nk", + "L\nl", + "Ø\nø", + "Æ\næ", + "*\n'", + { + "x": 4.75 + }, + "4", + { + "n": true + }, + "5", + "6" + ], + [ + { + "w": 1.25 + }, + "⇧ Shift", + ">\n<", + "Z\nz", + "X\nx", + "C\nc", + "V\nv", + "B\nb", + "N\nn", + "M\nm\n\nµ", + ";\n,", + ":\n.", + "_\n-", + { + "w": 2.75 + }, + "⇧ Shift", + { + "x": 1.25 + }, + "↑", + { + "x": 1.25 + }, + "1", + "2", + "3", + { + "h": 2 + }, + "⏎" + ], + [ + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 6.25 + }, + "Space", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "☰ Menu", + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "x": 0.25 + }, + "←", + "↓", + "→", + { + "x": 0.25, + "w": 2 + }, + "0", + "," + ] +] diff --git a/internal/keyboard/layouts/pl_PL.kle.json b/internal/keyboard/layouts/pl_PL.kle.json new file mode 100644 index 000000000..a2feb4d62 --- /dev/null +++ b/internal/keyboard/layouts/pl_PL.kle.json @@ -0,0 +1,235 @@ +[ + { + "name": "Polski pl-PL (ISO 105)", + "author": "JetKVM", + "kbdLayoutInfo": "https://kbdlayout.info/00000415/" + }, + [ + "Esc", + { + "x": 1 + }, + "F1", + "F2", + "F3", + "F4", + { + "x": 0.5 + }, + "F5", + "F6", + "F7", + "F8", + { + "x": 0.5 + }, + "F9", + "F10", + "F11", + "F12", + { + "x": 0.25 + }, + "PrtSc", + "ScrLk", + "Pause", + { + "x": 0.25, + "d": true, + "w": 4 + }, + "Polski\npl-PL\n\n(ISO 105)" + ], + [ + { + "y": 0.5 + }, + "~\n`", + "!\n1", + "@\n2", + "#\n3", + "$\n4", + "%\n5", + "^\n6", + "&\n7", + "*\n8", + "(\n9", + ")\n0", + "_\n-", + "+\n=", + { + "w": 2 + }, + "⌫", + { + "x": 0.25 + }, + "Ins", + "Home", + "PgUp", + { + "x": 0.25 + }, + "Num", + "/", + "*", + "-" + ], + [ + { + "w": 1.5 + }, + "⭾", + "Q\nq", + "W\nw", + "E\ne\nĘ\nę", + "R\nr", + "T\nt", + "Y\ny", + "U\nu\n\n€", + "I\ni", + "O\no\nÓ\nó", + "P\np", + "{\n[", + "}\n]", + { + "x": 0.25, + "w": 1.25, + "h": 2, + "w2": 1.5, + "h2": 1, + "x2": -0.25 + }, + "⏎", + { + "x": 0.25 + }, + "Del", + "End", + "PgDn", + { + "x": 0.25 + }, + "7", + "8", + "9", + { + "h": 2 + }, + "+" + ], + [ + { + "w": 1.75 + }, + "⇪", + "A\na\nĄ\ną", + "S\ns\nŚ\nś", + "D\nd", + { + "n": true + }, + "F\nf", + "G\ng", + "H\nh", + { + "n": true + }, + "J\nj", + "K\nk", + "L\nl\nŁ\nł", + ":\n;", + "\"\n'", + "|\n\\", + { + "x": 4.75 + }, + "4", + { + "n": true + }, + "5", + "6" + ], + [ + { + "w": 1.25 + }, + "⇧ Shift", + "|\n\\", + "Z\nz\nŻ\nż", + "X\nx\nŹ\nź", + "C\nc\nĆ\nć", + "V\nv", + "B\nb", + "N\nn\nŃ\nń", + "M\nm", + "<\n,", + ">\n.", + "?\n/", + { + "w": 2.75 + }, + "⇧ Shift", + { + "x": 1.25 + }, + "↑", + { + "x": 1.25 + }, + "1", + "2", + "3", + { + "h": 2 + }, + "⏎" + ], + [ + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 6.25 + }, + "Space", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "☰ Menu", + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "x": 0.25 + }, + "←", + "↓", + "→", + { + "x": 0.25, + "w": 2 + }, + "0", + "," + ] +] diff --git a/internal/keyboard/layouts/pt_PT.kle.json b/internal/keyboard/layouts/pt_PT.kle.json new file mode 100644 index 000000000..e245e56a7 --- /dev/null +++ b/internal/keyboard/layouts/pt_PT.kle.json @@ -0,0 +1,242 @@ +[ + { + "name": "Português pt-PT (ISO 105)", + "author": "JetKVM", + "deadKeys": [ + "~", + "´", + "`", + "^", + "¨" + ], + "kbdLayoutInfo": "https://kbdlayout.info/00000816/" + }, + [ + "Esc", + { + "x": 1 + }, + "F1", + "F2", + "F3", + "F4", + { + "x": 0.5 + }, + "F5", + "F6", + "F7", + "F8", + { + "x": 0.5 + }, + "F9", + "F10", + "F11", + "F12", + { + "x": 0.25 + }, + "PrtSc", + "ScrLk", + "Pause", + { + "x": 0.25, + "d": true, + "w": 4 + }, + "Português\npt-PT\n\n(ISO 105)" + ], + [ + { + "y": 0.5 + }, + "|\n\\", + "!\n1", + "\"\n2\n\n@", + "#\n3\n\n£", + "$\n4\n\n§", + "%\n5\n\n€", + "&\n6", + "/\n7\n\n{", + "(\n8\n\n[", + ")\n9\n\n]", + "=\n0\n\n}", + "?\n'", + "»\n«", + { + "w": 2 + }, + "⌫", + { + "x": 0.25 + }, + "Ins", + "Home", + "PgUp", + { + "x": 0.25 + }, + "Num", + "/", + "*", + "-" + ], + [ + { + "w": 1.5 + }, + "⭾", + "Q\nq", + "W\nw", + "E\ne\n\n€", + "R\nr", + "T\nt", + "Y\ny", + "U\nu", + "I\ni", + "O\no", + "P\np", + "*\n+\n\n¨", + "`\n´\n\n]", + { + "x": 0.25, + "w": 1.25, + "h": 2, + "w2": 1.5, + "h2": 1, + "x2": -0.25 + }, + "⏎", + { + "x": 0.25 + }, + "Del", + "End", + "PgDn", + { + "x": 0.25 + }, + "7", + "8", + "9", + { + "h": 2 + }, + "+" + ], + [ + { + "w": 1.75 + }, + "⇪", + "A\na", + "S\ns", + "D\nd", + { + "n": true + }, + "F\nf", + "G\ng", + "H\nh", + { + "n": true + }, + "J\nj", + "K\nk", + "L\nl", + "Ç\nç", + "ª\nº", + "^\n~", + { + "x": 4.75 + }, + "4", + { + "n": true + }, + "5", + "6" + ], + [ + { + "w": 1.25 + }, + "⇧ Shift", + ">\n<", + "Z\nz", + "X\nx", + "C\nc", + "V\nv", + "B\nb", + "N\nn", + "M\nm", + ";\n,", + ":\n.", + "_\n-", + { + "w": 2.75 + }, + "⇧ Shift", + { + "x": 1.25 + }, + "↑", + { + "x": 1.25 + }, + "1", + "2", + "3", + { + "h": 2 + }, + "⏎" + ], + [ + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 6.25 + }, + "Space", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "☰ Menu", + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "x": 0.25 + }, + "←", + "↓", + "→", + { + "x": 0.25, + "w": 2 + }, + "0", + "." + ] +] diff --git a/internal/keyboard/layouts/ru_RU.kle.json b/internal/keyboard/layouts/ru_RU.kle.json new file mode 100644 index 000000000..8dc71f59a --- /dev/null +++ b/internal/keyboard/layouts/ru_RU.kle.json @@ -0,0 +1,235 @@ +[ + { + "name": "Русская ru-RU (ISO 105)", + "author": "JetKVM", + "kbdLayoutInfo": "https://kbdlayout.info/00000419/" + }, + [ + "Esc", + { + "x": 1 + }, + "F1", + "F2", + "F3", + "F4", + { + "x": 0.5 + }, + "F5", + "F6", + "F7", + "F8", + { + "x": 0.5 + }, + "F9", + "F10", + "F11", + "F12", + { + "x": 0.25 + }, + "PrtSc", + "ScrLk", + "Pause", + { + "x": 0.25, + "d": true, + "w": 4 + }, + "Русская\nru-RU\n\n(ISO 105)" + ], + [ + { + "y": 0.5 + }, + "Ё\nё", + "!\n1", + "\"\n2", + "№\n3", + ";\n4", + "%\n5", + ":\n6", + "?\n7", + "*\n8\n\n₽", + "(\n9", + ")\n0", + "_\n-", + "+\n=", + { + "w": 2 + }, + "⌫", + { + "x": 0.25 + }, + "Ins", + "Home", + "PgUp", + { + "x": 0.25 + }, + "Num", + "/", + "*", + "-" + ], + [ + { + "w": 1.5 + }, + "⭾", + "Й\nй", + "Ц\nц", + "У\nу", + "К\nк", + "Е\nе", + "Н\nн", + "Г\nг", + "Ш\nш", + "Щ\nщ", + "З\nз", + "Х\nх", + "Ъ\nъ", + { + "x": 0.25, + "w": 1.25, + "h": 2, + "w2": 1.5, + "h2": 1, + "x2": -0.25 + }, + "⏎", + { + "x": 0.25 + }, + "Del", + "End", + "PgDn", + { + "x": 0.25 + }, + "7", + "8", + "9", + { + "h": 2 + }, + "+" + ], + [ + { + "w": 1.75 + }, + "⇪", + "Ф\nф", + "Ы\nы", + "В\nв", + { + "n": true + }, + "А\nа", + "П\nп", + "Р\nр", + { + "n": true + }, + "О\nо", + "Л\nл", + "Д\nд", + "Ж\nж", + "Э\nэ", + "/\n\\", + { + "x": 4.75 + }, + "4", + { + "n": true + }, + "5", + "6" + ], + [ + { + "w": 1.25 + }, + "⇧ Shift", + "[\n]", + "Я\nя", + "Ч\nч", + "С\nс", + "М\nм", + "И\nи", + "Т\nт", + "Ь\nь", + "Б\nб", + "Ю\nю", + ",\n.", + { + "w": 2.75 + }, + "⇧ Shift", + { + "x": 1.25 + }, + "↑", + { + "x": 1.25 + }, + "1", + "2", + "3", + { + "h": 2 + }, + "⏎" + ], + [ + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 6.25 + }, + "Space", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "☰ Menu", + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "x": 0.25 + }, + "←", + "↓", + "→", + { + "x": 0.25, + "w": 2 + }, + "0", + "," + ] +] diff --git a/internal/keyboard/layouts/sl_SI.kle.json b/internal/keyboard/layouts/sl_SI.kle.json new file mode 100644 index 000000000..5a5c6f480 --- /dev/null +++ b/internal/keyboard/layouts/sl_SI.kle.json @@ -0,0 +1,239 @@ +[ + { + "name": "Slovenian sl-SI (ISO 105)", + "author": "JetKVM", + "deadKeys": [ + "¸", + "¨" + ], + "kbdLayoutInfo": "https://kbdlayout.info/00000424/" + }, + [ + "Esc", + { + "x": 1 + }, + "F1", + "F2", + "F3", + "F4", + { + "x": 0.5 + }, + "F5", + "F6", + "F7", + "F8", + { + "x": 0.5 + }, + "F9", + "F10", + "F11", + "F12", + { + "x": 0.25 + }, + "PrtSc", + "ScrLk", + "Pause", + { + "x": 0.25, + "d": true, + "w": 4 + }, + "Slovenian\nsl-SI\n\n(ISO 105)" + ], + [ + { + "y": 0.5 + }, + "¨\n¸", + "!\n1\n\n~", + "\"\n2\n\nˇ", + "#\n3\n\n^", + "$\n4\n\n˘", + "%\n5\n\n°", + "&\n6\n\n˛", + "/\n7\n\n`", + "(\n8\n\n˙", + ")\n9\n\n´", + "=\n0\n\n˝", + "?\n'\n\n¨", + "*\n+\n\n¸", + { + "w": 2 + }, + "⌫", + { + "x": 0.25 + }, + "Ins", + "Home", + "PgUp", + { + "x": 0.25 + }, + "Num", + "/", + "*", + "-" + ], + [ + { + "w": 1.5 + }, + "⭾", + "Q\nq\n\n\\", + "W\nw\n\n|", + "E\ne\n\n€", + "R\nr", + "T\nt", + "Z\nz", + "U\nu", + "I\ni", + "O\no", + "P\np", + "Š\nš\n\n÷", + "Đ\nđ\n\n×", + { + "x": 0.25, + "w": 1.25, + "h": 2, + "w2": 1.5, + "h2": 1, + "x2": -0.25 + }, + "⏎", + { + "x": 0.25 + }, + "Del", + "End", + "PgDn", + { + "x": 0.25 + }, + "7", + "8", + "9", + { + "h": 2 + }, + "+" + ], + [ + { + "w": 1.75 + }, + "⇪", + "A\na", + "S\ns", + "D\nd", + { + "n": true + }, + "F\nf\n\n[", + "G\ng\n\n]", + "H\nh", + { + "n": true + }, + "J\nj", + "K\nk\n\nł", + "L\nl\n\nŁ", + "Č\nč", + "Ć\nć\n\nß", + "Ž\nž\n\n¤", + { + "x": 4.75 + }, + "4", + { + "n": true + }, + "5", + "6" + ], + [ + { + "w": 1.25 + }, + "⇧ Shift", + ">\n<", + "Y\ny", + "X\nx", + "C\nc", + "V\nv\n\n@", + "B\nb\n\n{", + "N\nn\n\n}", + "M\nm\n\n§", + ";\n,\n\n<", + ":\n.\n\n>", + "_\n-", + { + "w": 2.75 + }, + "⇧ Shift", + { + "x": 1.25 + }, + "↑", + { + "x": 1.25 + }, + "1", + "2", + "3", + { + "h": 2 + }, + "⏎" + ], + [ + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 6.25 + }, + "Space", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "☰ Menu", + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "x": 0.25 + }, + "←", + "↓", + "→", + { + "x": 0.25, + "w": 2 + }, + "0", + "," + ] +] diff --git a/internal/keyboard/layouts/sv_SE.kle.json b/internal/keyboard/layouts/sv_SE.kle.json new file mode 100644 index 000000000..d44157ea1 --- /dev/null +++ b/internal/keyboard/layouts/sv_SE.kle.json @@ -0,0 +1,242 @@ +[ + { + "name": "Svenska sv-SE (ISO 105)", + "author": "JetKVM", + "deadKeys": [ + "¨", + "´", + "^", + "`", + "~" + ], + "kbdLayoutInfo": "https://kbdlayout.info/0000041D/" + }, + [ + "Esc", + { + "x": 1 + }, + "F1", + "F2", + "F3", + "F4", + { + "x": 0.5 + }, + "F5", + "F6", + "F7", + "F8", + { + "x": 0.5 + }, + "F9", + "F10", + "F11", + "F12", + { + "x": 0.25 + }, + "PrtSc", + "ScrLk", + "Pause", + { + "x": 0.25, + "d": true, + "w": 4 + }, + "Svenska\nsv-SE\n\n(ISO 105)" + ], + [ + { + "y": 0.5 + }, + "½\n§", + "!\n1", + "\"\n2\n\n@", + "#\n3\n\n£", + "¤\n4\n\n$", + "%\n5\n\n€", + "&\n6", + "/\n7\n\n{", + "(\n8\n\n[", + ")\n9\n\n]", + "=\n0\n\n}", + "?\n+\n\n\\", + "`\n´", + { + "w": 2 + }, + "⌫", + { + "x": 0.25 + }, + "Ins", + "Home", + "PgUp", + { + "x": 0.25 + }, + "Num", + "/", + "*", + "-" + ], + [ + { + "w": 1.5 + }, + "⭾", + "Q\nq", + "W\nw", + "E\ne\n\n€", + "R\nr", + "T\nt", + "Y\ny", + "U\nu", + "I\ni", + "O\no", + "P\np", + "Å\nå", + "^\n¨\n\n~", + { + "x": 0.25, + "w": 1.25, + "h": 2, + "w2": 1.5, + "h2": 1, + "x2": -0.25 + }, + "⏎", + { + "x": 0.25 + }, + "Del", + "End", + "PgDn", + { + "x": 0.25 + }, + "7", + "8", + "9", + { + "h": 2 + }, + "+" + ], + [ + { + "w": 1.75 + }, + "⇪", + "A\na", + "S\ns", + "D\nd", + { + "n": true + }, + "F\nf", + "G\ng", + "H\nh", + { + "n": true + }, + "J\nj", + "K\nk", + "L\nl", + "Ö\nö", + "Ä\nä", + "*\n'", + { + "x": 4.75 + }, + "4", + { + "n": true + }, + "5", + "6" + ], + [ + { + "w": 1.25 + }, + "⇧ Shift", + ">\n<\n\n|", + "Z\nz", + "X\nx", + "C\nc", + "V\nv", + "B\nb", + "N\nn", + "M\nm\n\nµ", + ";\n,", + ":\n.", + "_\n-", + { + "w": 2.75 + }, + "⇧ Shift", + { + "x": 1.25 + }, + "↑", + { + "x": 1.25 + }, + "1", + "2", + "3", + { + "h": 2 + }, + "⏎" + ], + [ + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 6.25 + }, + "Space", + { + "w": 1.25 + }, + "⌥ Alt", + { + "w": 1.25 + }, + "⌘ Meta", + { + "w": 1.25 + }, + "☰ Menu", + { + "w": 1.25 + }, + "⌃ Ctrl", + { + "x": 0.25 + }, + "←", + "↓", + "→", + { + "x": 0.25, + "w": 2 + }, + "0", + "," + ] +] diff --git a/internal/keyboard/scancode.go b/internal/keyboard/scancode.go new file mode 100644 index 000000000..ad58aad76 --- /dev/null +++ b/internal/keyboard/scancode.go @@ -0,0 +1,575 @@ +// internal/keyboard/scancode.go +// +// Infers USB HID Usage IDs from physical key positions in the KLE grid. +// +// Standard keyboard grids are well-defined. This table covers ANSI and ISO +// standard layouts. For non-standard boards (JIS, ABNT2, ortholinear). +// You can override individual key scancodes post-parse with a "scancodes" +// dictionary in the KLE.json file. +// +// HID Usage Table: USB HID Usage Tables 1.3, Keyboard/Keypad Page (0x07) +// https://usb.org/sites/default/files/hut1_3_0.pdf + +package keyboard + +import "math" + +// HID Usage IDs for standard keys (page 0x07) +const ( + // Letters + hidA uint8 = 0x04 + hidB uint8 = 0x05 + hidC uint8 = 0x06 + hidD uint8 = 0x07 + hidE uint8 = 0x08 + hidF uint8 = 0x09 + hidG uint8 = 0x0A + hidH uint8 = 0x0B + hidI uint8 = 0x0C + hidJ uint8 = 0x0D + hidK uint8 = 0x0E + hidL uint8 = 0x0F + hidM uint8 = 0x10 + hidN uint8 = 0x11 + hidO uint8 = 0x12 + hidP uint8 = 0x13 + hidQ uint8 = 0x14 + hidR uint8 = 0x15 + hidS uint8 = 0x16 + hidT uint8 = 0x17 + hidU uint8 = 0x18 + hidV uint8 = 0x19 + hidW uint8 = 0x1A + hidX uint8 = 0x1B + hidY uint8 = 0x1C + hidZ uint8 = 0x1D + + // Number row + hidN1 uint8 = 0x1E + hidN2 uint8 = 0x1F + hidN3 uint8 = 0x20 + hidN4 uint8 = 0x21 + hidN5 uint8 = 0x22 + hidN6 uint8 = 0x23 + hidN7 uint8 = 0x24 + hidN8 uint8 = 0x25 + hidN9 uint8 = 0x26 + hidN0 uint8 = 0x27 + + // Punctuation + hidEnter uint8 = 0x28 + hidEscape uint8 = 0x29 + hidBackspace uint8 = 0x2A + hidTab uint8 = 0x2B + hidSpace uint8 = 0x2C + hidMinus uint8 = 0x2D // - _ + hidEqual uint8 = 0x2E // = + + hidLBracket uint8 = 0x2F // [ { + hidRBracket uint8 = 0x30 // ] } + hidBackslash uint8 = 0x31 // \ | + hidHashTilde uint8 = 0x32 // # ~ (ISO layouts only) + hidSemicolon uint8 = 0x33 // ; : + hidQuote uint8 = 0x34 // ' " + hidGrave uint8 = 0x35 // ` ~ + hidComma uint8 = 0x36 // , < + hidPeriod uint8 = 0x37 // . > + hidSlash uint8 = 0x38 // / ? + + hidF1 uint8 = 0x3A + hidF2 uint8 = 0x3B + hidF3 uint8 = 0x3C + hidF4 uint8 = 0x3D + hidF5 uint8 = 0x3E + hidF6 uint8 = 0x3F + hidF7 uint8 = 0x40 + hidF8 uint8 = 0x41 + hidF9 uint8 = 0x42 + hidF10 uint8 = 0x43 + hidF11 uint8 = 0x44 + hidF12 uint8 = 0x45 + + // Navigation cluster + hidPrintScreen uint8 = 0x46 + hidScrollLock uint8 = 0x47 + hidPause uint8 = 0x48 + hidInsert uint8 = 0x49 + hidHome uint8 = 0x4A + hidPageUp uint8 = 0x4B + hidDelete uint8 = 0x4C + hidEnd uint8 = 0x4D + hidPageDown uint8 = 0x4E + + // Arrow keys + hidArrowRight uint8 = 0x4F + hidArrowLeft uint8 = 0x50 + hidArrowDown uint8 = 0x51 + hidArrowUp uint8 = 0x52 + + // Numpad + hidNumLock uint8 = 0x53 // aka CLEAR + hidKPSlash uint8 = 0x54 + hidKPStar uint8 = 0x55 + hidKPMinus uint8 = 0x56 + hidKPPlus uint8 = 0x57 + hidKPEnter uint8 = 0x58 + hidKP1 uint8 = 0x59 + hidKP2 uint8 = 0x5A + hidKP3 uint8 = 0x5B + hidKP4 uint8 = 0x5C + hidKP5 uint8 = 0x5D + hidKP6 uint8 = 0x5E + hidKP7 uint8 = 0x5F + hidKP8 uint8 = 0x60 + hidKP9 uint8 = 0x61 + hidKP0 uint8 = 0x62 + hidKPDot uint8 = 0x63 + + // key between LShift and Z on ISO layouts + hidISOKey uint8 = 0x64 // Non-US ` | + + // Modifiers (Usage Page 0x07) + hidLCtrl uint8 = 0xE0 + hidLShift uint8 = 0xE1 + hidLAlt uint8 = 0xE2 + hidLMeta uint8 = 0xE3 // aka LGUI (Windows key, Command key) + hidRCtrl uint8 = 0xE4 + hidRShift uint8 = 0xE5 + hidRAlt uint8 = 0xE6 // aka AltGr + hidRMeta uint8 = 0xE7 // aka LGUI (Windows key, Command key) + + hidCapsLock uint8 = 0x39 + + // Additional keys that may not be present on all layouts + hidApplication uint8 = 0x65 + + // These keys are less common and may not be present on many keyboards, + // but we document them for completeness. + /* + hidPower uint8 = 0x66 + hidKPEquals uint8 = 0x67 + + hidF13 uint8 = 0x68 + hidF14 uint8 = 0x69 + hidF15 uint8 = 0x6A + hidF16 uint8 = 0x6B + hidF17 uint8 = 0x6C + hidF18 uint8 = 0x6D + hidF19 uint8 = 0x6E + hidF20 uint8 = 0x6F + hidF21 uint8 = 0x70 + hidF22 uint8 = 0x71 + hidF23 uint8 = 0x72 + hidF24 uint8 = 0x73 + + hidExecute uint8 = 0x74 + hidHelp uint8 = 0x75 + hidMenu uint8 = 0x76 + hidSelect uint8 = 0x77 + hidStop uint8 = 0x78 + hidAgain uint8 = 0x79 + hidUndo uint8 = 0x7A + hidCut uint8 = 0x7B + hidCopy uint8 = 0x7C + hidPaste uint8 = 0x7D + hidFind uint8 = 0x7E + hidMute uint8 = 0x7F + + hidVolumeUp uint8 = 0x80 + hidVolumeDown uint8 = 0x81 + + hidLockingCapsLock uint8 = 0x82 + hidLockingNumLock uint8 = 0x83 + hidLockingScrollLock uint8 = 0x84 + + hidKPComma uint8 = 0x85 + hidKPEqualAS400 uint8 = 0x86 + + hidIntl1 uint8 = 0x87 + hidIntl2 uint8 = 0x88 + hidIntl3 uint8 = 0x89 + hidIntl4 uint8 = 0x8A + hidIntl5 uint8 = 0x8B + hidIntl6 uint8 = 0x8C + hidIntl7 uint8 = 0x8D + hidIntl8 uint8 = 0x8E + hidIntl9 uint8 = 0x8F + + hidLang1 uint8 = 0x90 + hidLang2 uint8 = 0x91 + hidLang3 uint8 = 0x92 + hidLang4 uint8 = 0x93 + hidLang5 uint8 = 0x94 + hidLang6 uint8 = 0x95 + hidLang7 uint8 = 0x96 + hidLang8 uint8 = 0x97 + hidLang9 uint8 = 0x98 + + hidErase uint8 = 0x99 + hidSysReq uint8 = 0x9A + hidCancel uint8 = 0x9B + hidClear uint8 = 0x9C + hidPrior uint8 = 0x9D + hidReturn uint8 = 0x9E + hidSeparator uint8 = 0x9F + hidOut uint8 = 0xA0 + hidOper uint8 = 0xA1 + hidClearAgain uint8 = 0xA2 + hidCrSel uint8 = 0xA3 + hidExSel uint8 = 0xA4 + + hidKP00 uint8 = 0xB0 + hidKP000 uint8 = 0xB1 + hidThousandsSeparator uint8 = 0xB2 + hidDecimalSeparator uint8 = 0xB3 + hidCurrencyUnit uint8 = 0xB4 + hidCurrencySubUnit uint8 = 0xB5 + + hidKPLeftParen uint8 = 0xB6 + hidKPRightParen uint8 = 0xB7 + hidKPLeftBrace uint8 = 0xB8 + hidKPRightBrace uint8 = 0xB9 + + hidKPTab uint8 = 0xBA + hidKPBackspace uint8 = 0xBB + + hidKPA uint8 = 0xBC + hidKPB uint8 = 0xBD + hidKPC uint8 = 0xBE + hidKPD uint8 = 0xBF + hidKPE uint8 = 0xC0 + hidKPF uint8 = 0xC1 + + hidKPXor uint8 = 0xC2 + hidKPCaret uint8 = 0xC3 + hidKPPercent uint8 = 0xC4 + hidKPLess uint8 = 0xC5 + hidKPGreater uint8 = 0xC6 + hidKPAnd uint8 = 0xC7 + hidKPAndAnd uint8 = 0xC8 + hidKPOr uint8 = 0xC9 + hidKPOrOr uint8 = 0xCA + hidKPColon uint8 = 0xCC + hidKPHash uint8 = 0xCD + hidKPSpace uint8 = 0xCE + hidKPAt uint8 = 0xCF + hidKPExclam uint8 = 0xD0 + + hidKPMemStore uint8 = 0xD1 + hidKPMemRecall uint8 = 0xD2 + hidKPMemClear uint8 = 0xD3 + hidKPMemAdd uint8 = 0xD4 + hidKPMemSubtract uint8 = 0xD5 + hidKPMemMultiply uint8 = 0xD6 + hidKPMemDivide uint8 = 0xD7 + + hidKPPlusMinus uint8 = 0xD8 + hidKPClear uint8 = 0xD9 + hidKPClearEntry uint8 = 0xDA + + hidKPBinary uint8 = 0xDB + hidKPOctal uint8 = 0xDC + hidKPDecimal uint8 = 0xDD + hidKPHexadecimal uint8 = 0xDE + */ + + hidUnknown uint8 = 0x00 +) + +// IsModifierScancode reports whether sc is a keyboard modifier usage ID +// (Left/Right Ctrl, Shift, Alt, Meta). +func IsModifierScancode(sc uint8) bool { + return sc >= hidLCtrl && sc <= hidRMeta +} + +// ScancodeProducesText reports whether sc produces typed text on standard +// keyboard layouts. Includes letters, digits, space, printable punctuation, +// the ISO key (0x64), and printable numpad keys. Excludes control keys whose +// HID usage IDs sit inside those ranges (Enter, Escape, Backspace, Tab, +// NumLock, KPEnter). +// +// Keep this aligned with ui/src/keyboardMappings.ts (scancodeProducesText). +func ScancodeProducesText(scancode uint8) bool { + // Alphabet keys + if scancode >= hidA && scancode <= hidZ { + return true + } + // Number row keys + if scancode >= hidN1 && scancode <= hidN0 { + return true + } + // Space and printable punctuation keys. Excludes Enter/Escape/Backspace/Tab. + if scancode == hidSpace || + (scancode >= hidMinus && scancode <= hidSlash) || + scancode == hidHashTilde || + scancode == hidISOKey { + return true + } + // Numpad printable characters. Excludes NumLock and KPEnter. + if (scancode >= hidKPSlash && scancode <= hidKPPlus) || + (scancode >= hidKP1 && scancode <= hidKPDot) { + return true + } + + return false +} + +// IsControlScancode reports whether sc should be treated as a control-like key +// for legend and layer-display logic. The explicit switch list covers keys +// like Space and Enter that the rest of the code treats as control even though +// Space technically produces a typed character. +// +// Keep this aligned with ui/src/keyboardMappings.ts. +func IsControlScancode(sc uint8) bool { + if IsModifierScancode(sc) { + return true + } + + switch sc { + case hidEscape, + hidEnter, + hidBackspace, + hidTab, + hidSpace, + hidCapsLock, + hidPrintScreen, + hidScrollLock, + hidPause, + hidInsert, + hidDelete, + hidHome, + hidEnd, + hidPageUp, + hidPageDown, + hidArrowUp, + hidArrowDown, + hidArrowLeft, + hidArrowRight, + hidNumLock, + hidKPEnter, + hidApplication: + return true + } + + return !ScancodeProducesText(sc) +} + +// --------------------------------------------------------------------------- +// Position table +// --------------------------------------------------------------------------- + +// posEntry maps an x-start position to an HID Usage ID. +type posEntry struct { + xStart float64 + scancode uint8 +} + +// Standard ANSI/ISO key grid. +// +// positionTable maps integer row index (y rounded) to a sorted list of +// (x_start, scancode) pairs. A key at position x matches the entry with +// the largest xStart that is still <= x. +// Full-size position table. Positions match the real KLE coordinate output +// from keyboard-layout-editor.com for standard ANSI 104 / ISO 105 layouts. +// +// Note: The standard KLE template has a y:0.5 gap between the function row +// and the number row, so rows are at y=0, 1.5, 2.5, 3.5, 4.5, 5.5. +// We use math.Round(y) in inferScancodeWithTable, so: +// +// y=0.00 → row 0 (function) +// y=1.50 → row 2 (number) — rounds to 2, not 1 +// y=2.50 → row 3 (qwerty) — rounds to 3, not 2 +// y=3.50 → row 4 (home) — rounds to 4 +// y=4.50 → row 5 (shift) — rounds to 5, not 4 +// y=5.50 → row 6 (modifiers) — rounds to 6, not 5 +var fullSizeTable = map[int][]posEntry{ + // Function row (y=0) + 0: { + {0, hidEscape}, + {2, hidF1}, {3, hidF2}, {4, hidF3}, {5, hidF4}, + {6.5, hidF5}, {7.5, hidF6}, {8.5, hidF7}, {9.5, hidF8}, + {11, hidF9}, {12, hidF10}, {13, hidF11}, {14, hidF12}, + {15.25, hidPrintScreen}, {16.25, hidScrollLock}, {17.25, hidPause}, + }, + // Number row (y≈1.5 → rounds to 2) + 2: { + {0, hidGrave}, + {1, hidN1}, {2, hidN2}, {3, hidN3}, {4, hidN4}, {5, hidN5}, + {6, hidN6}, {7, hidN7}, {8, hidN8}, {9, hidN9}, {10, hidN0}, + {11, hidMinus}, {12, hidEqual}, {13, hidBackspace}, + // Nav cluster + {15.25, hidInsert}, {16.25, hidHome}, {17.25, hidPageUp}, + // Numpad + {18.5, hidNumLock}, {19.5, hidKPSlash}, {20.5, hidKPStar}, {21.5, hidKPMinus}, + }, + // QWERTY row (y≈2.5 → rounds to 3) + 3: { + {0, hidTab}, + {1.5, hidQ}, {2.5, hidW}, {3.5, hidE}, {4.5, hidR}, {5.5, hidT}, + {6.5, hidY}, {7.5, hidU}, {8.5, hidI}, {9.5, hidO}, {10.5, hidP}, + {11.5, hidLBracket}, {12.5, hidRBracket}, {13.5, hidBackslash}, + // Nav cluster + {15.25, hidDelete}, {16.25, hidEnd}, {17.25, hidPageDown}, + // Numpad + {18.5, hidKP7}, {19.5, hidKP8}, {20.5, hidKP9}, {21.5, hidKPPlus}, + }, + // Home row (y≈3.5 → rounds to 4) + 4: { + {0, hidCapsLock}, + {1.75, hidA}, {2.75, hidS}, {3.75, hidD}, {4.75, hidF}, {5.75, hidG}, + {6.75, hidH}, {7.75, hidJ}, {8.75, hidK}, {9.75, hidL}, + {10.75, hidSemicolon}, {11.75, hidQuote}, {12.75, hidEnter}, + // Numpad + {18.5, hidKP4}, {19.5, hidKP5}, {20.5, hidKP6}, + }, + // Shift row (y≈4.5 → rounds to 5) + 5: { + {0, hidLShift}, + {1.25, hidISOKey}, // ISO key (between LShift and Z on ISO layouts) + {2.25, hidZ}, // ANSI Z starts at 2.25 (after 2.25u LShift) + {3.25, hidX}, {4.25, hidC}, {5.25, hidV}, {6.25, hidB}, + {7.25, hidN}, {8.25, hidM}, + {9.25, hidComma}, {10.25, hidPeriod}, {11.25, hidSlash}, + {12.25, hidRShift}, + // Arrow up + {16.25, hidArrowUp}, + // Numpad + {18.5, hidKP1}, {19.5, hidKP2}, {20.5, hidKP3}, {21.5, hidKPEnter}, + }, + // Modifier row (y≈5.5 → rounds to 6) + 6: { + {0, hidLCtrl}, {1.25, hidLMeta}, {2.5, hidLAlt}, + {3.75, hidSpace}, + {10, hidRAlt}, {11.25, hidRMeta}, {12.5, hidApplication}, + {13.75, hidRCtrl}, + // Arrow keys + {15.25, hidArrowLeft}, {16.25, hidArrowDown}, {17.25, hidArrowRight}, + // Numpad + {18.5, hidKP0}, {20.5, hidKPDot}, + }, +} + +// Compact layout table — for keyboards WITHOUT the y:0.5 gap between +// the function row and number row (75%, TKL). +// Rows are at y=0, 1, 2, 3, 4, 5 (no fractional Y offsets). +// The main typing area positions are identical to full-size. +// +// Note: 60%/65% keyboards that lack a function row are NOT supported +// by this table — their number row at y=0 would be misinterpreted as +// F-keys. Those layouts need scancode overrides in their KLE metadata. +var compactTable = map[int][]posEntry{ + // Row 0: function row (75%/TKL — Esc and F1-F12 packed together) + 0: { + {0, hidEscape}, + {1, hidF1}, {2, hidF2}, {3, hidF3}, {4, hidF4}, + {5, hidF5}, {6, hidF6}, {7, hidF7}, {8, hidF8}, + {9, hidF9}, {10, hidF10}, {11, hidF11}, {12, hidF12}, + // Right-side keys (75%: PrtSc/ScrLk area) + {13, hidPrintScreen}, {14, hidScrollLock}, {15, hidPause}, + }, + // Number row (y=1) + 1: { + {0, hidGrave}, + {1, hidN1}, {2, hidN2}, {3, hidN3}, {4, hidN4}, {5, hidN5}, + {6, hidN6}, {7, hidN7}, {8, hidN8}, {9, hidN9}, {10, hidN0}, + {11, hidMinus}, {12, hidEqual}, {13, hidBackspace}, + // 75%: nav key on right + {15, hidInsert}, {16, hidHome}, + }, + // QWERTY row + 2: { + {0, hidTab}, + {1.5, hidQ}, {2.5, hidW}, {3.5, hidE}, {4.5, hidR}, {5.5, hidT}, + {6.5, hidY}, {7.5, hidU}, {8.5, hidI}, {9.5, hidO}, {10.5, hidP}, + {11.5, hidLBracket}, {12.5, hidRBracket}, {13.5, hidBackslash}, + {15, hidDelete}, {16, hidPageUp}, + }, + // Home row + 3: { + {0, hidCapsLock}, + {1.75, hidA}, {2.75, hidS}, {3.75, hidD}, {4.75, hidF}, {5.75, hidG}, + {6.75, hidH}, {7.75, hidJ}, {8.75, hidK}, {9.75, hidL}, + {10.75, hidSemicolon}, {11.75, hidQuote}, {12.75, hidEnter}, + {15, hidPageDown}, {16, hidEnd}, + }, + // Shift row + 4: { + {0, hidLShift}, + {1.25, hidISOKey}, + {2.25, hidZ}, + {3.25, hidX}, {4.25, hidC}, {5.25, hidV}, {6.25, hidB}, + {7.25, hidN}, {8.25, hidM}, + {9.25, hidComma}, {10.25, hidPeriod}, {11.25, hidSlash}, + {12.25, hidRShift}, + // Arrows (75%) + {14.25, hidArrowUp}, + {15, hidEnd}, + }, + // Modifier row + 5: { + {0, hidLCtrl}, {1.25, hidLMeta}, {2.5, hidLAlt}, + {3.75, hidSpace}, + {10, hidRAlt}, {11, hidRMeta}, {12, hidApplication}, {13, hidRCtrl}, + // Arrows + {13.25, hidArrowLeft}, {14.25, hidArrowDown}, {15.25, hidArrowRight}, + }, +} + +// selectPositionTable chooses the appropriate scancode table based on +// the keyboard's physical dimensions and key count. +func selectPositionTable(boardW, boardH float64, keyCount int) map[int][]posEntry { + // Full-size: wide board or many keys (104/105/109) + if boardW > 20 || keyCount >= 100 { + return fullSizeTable + } + // Compact layouts with a function row (75%, TKL): 6+ rows, 70+ keys + if boardH >= 6 && keyCount >= 70 { + return compactTable + } + // Everything else (60%, 65%, or very small boards): use full-size table + // as a best-effort fallback. These layouts will likely need scancode + // overrides in their KLE metadata for correct mapping. + return fullSizeTable +} + +// inferScancodeWithTable returns the USB HID Usage ID for a key at position +// (x, y) using the given position table. For ISO Enter (h >= 2, x < 15), +// returns hidEnter directly. +// +// Uses closest-match with tolerance: finds the table entry with minimum +// distance to the key position, and matches if distance < maxDistance. +func inferScancodeWithTable(x, y, w, h float64, table map[int][]posEntry) uint8 { + // ISO / big-ass Enter: spans two rows, in the main typing area (x < 15) + // Don't match numpad + or numpad Enter which also have h >= 2 + if h >= 2 && x < 15 { + return hidEnter + } + + // ISO hash/tilde key (#/~): on ISO layouts, the narrow key at x≈12.75 on the + // home row is the hash key (w≈1), not Enter (w≥2). The width check alone + // distinguishes them — no row index check needed, so this works for both + // full-size (home row at y≈3.5→4) and compact (home row at y=3) tables. + if approxEq(x, 12.75) && w < 1.5 { + return hidHashTilde + } + + rowIdx := int(math.Round(y)) + row, ok := table[rowIdx] + if !ok { + return hidUnknown + } + + // Closest-match algorithm: find the entry with minimum distance to x, + // match if distance <= maxDistance tolerance (0.3u). + // This handles positional variance across different keyboard layouts. + const maxDistance = 0.3 // tolerance for key position variance + var closest = hidUnknown + var minDist = math.Inf(1) + for _, entry := range row { + dist := math.Abs(entry.xStart - x) + if dist < minDist && dist <= maxDistance { + minDist = dist + closest = entry.scancode + } + } + return closest // Will still be hidUnknown if the closest entry is beyond tolerance +} diff --git a/internal/keyboard/testdata/ansi_60.kle.json b/internal/keyboard/testdata/ansi_60.kle.json new file mode 100644 index 000000000..11f4ede70 --- /dev/null +++ b/internal/keyboard/testdata/ansi_60.kle.json @@ -0,0 +1,31 @@ +[ + [ + "~\n`", "!\n1", "@\n2", "#\n3", "$\n4", "%\n5", "^\n6", "&\n7", "*\n8", "(\n9", ")\n0", "_\n-", "+\n=", + {"w": 2}, "Backspace" + ], + [ + {"w": 1.5}, + "Tab", "Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", "{\n[", "}\n]", + {"w": 1.5}, "|\n\\" + ], + [ + {"w": 1.75}, + "Caps Lock", "A", "S", "D", "F", "G", "H", "J", "K", "L", ":\n;", "\"\n'", + {"w": 2.25}, "Enter" + ], + [ + {"w": 2.25}, + "Shift", "Z", "X", "C", "V", "B", "N", "M", "<\n,", ">\n.", "?\n/", + {"w": 2.75}, "Shift" + ], + [ + {"w": 1.25}, "Ctrl", + {"w": 1.25}, "Win", + {"w": 1.25}, "Alt", + {"a": 7, "w": 6.25}, "", + {"a": 4, "w": 1.25}, "Alt", + {"w": 1.25}, "Win", + {"w": 1.25}, "Menu", + {"w": 1.25}, "Ctrl" + ] +] diff --git a/internal/keyboard/testdata/iso_60.kle.json b/internal/keyboard/testdata/iso_60.kle.json new file mode 100644 index 000000000..bb0075be0 --- /dev/null +++ b/internal/keyboard/testdata/iso_60.kle.json @@ -0,0 +1,30 @@ +[ + [ + "¬\n`", "!\n1", "\"\n2", "£\n3", "$\n4", "%\n5", "^\n6", "&\n7", "*\n8", "(\n9", ")\n0", "_\n-", "+\n=", + {"w": 2}, "Backspace" + ], + [ + {"w": 1.5}, + "Tab", "Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", "{\n[", "}\n]", + {"x": 0.25, "w": 1.25, "h": 2, "w2": 1.5, "h2": 1, "x2": -0.25}, "Enter" + ], + [ + {"w": 1.75}, + "Caps Lock", "A", "S", "D", "F", "G", "H", "J", "K", "L", ":\n;", "@\n'", "~\n#" + ], + [ + {"w": 1.25}, + "Shift", "|\n\\", "Z", "X", "C", "V", "B", "N", "M", "<\n,", ">\n.", "?\n/", + {"w": 2.75}, "Shift" + ], + [ + {"w": 1.25}, "Ctrl", + {"w": 1.25}, "Win", + {"w": 1.25}, "Alt", + {"a": 7, "w": 6.25}, "", + {"a": 4, "w": 1.25}, "AltGr", + {"w": 1.25}, "Win", + {"w": 1.25}, "Menu", + {"w": 1.25}, "Ctrl" + ] +] diff --git a/internal/keyboard/testdata/keycool_84.kle.json b/internal/keyboard/testdata/keycool_84.kle.json new file mode 100644 index 000000000..6e9abdcb4 --- /dev/null +++ b/internal/keyboard/testdata/keycool_84.kle.json @@ -0,0 +1,38 @@ +[ + [ + {"a": 6}, + "Esc", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12", + {"a": 5}, "PrtSc\nNmLk", "Pause\nScrLk", "Delete\nInsert" + ], + [ + {"a": 4}, + "~\n`", "!\n1", "@\n2", "#\n3", "$\n4", "%\n5", "^\n6", "&\n7", "*\n8", "(\n9", ")\n0", "_\n-", "+\n=", + {"a": 6, "w": 2}, "Backspace", "Home" + ], + [ + {"a": 4, "w": 1.5}, + "Tab", "Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", "{\n[", "}\n]", + {"w": 1.5}, "|\n\\", + {"a": 6}, "Page Up" + ], + [ + {"a": 4, "w": 1.75}, + "Caps Lock", "A", "S", "D", "F", "G", "H", "J", "K", "L", ":\n;", "\"\n'", + {"a": 6, "w": 2.25}, "Enter", "Page Down" + ], + [ + {"w": 2.25}, "Shift", + {"a": 4}, "Z", "X", "C", "V", "B", "N", "M", "<\n,", ">\n.", "?\n/", + {"a": 6, "w": 1.75}, "Shift", + {"a": 7}, "↑", + {"a": 6}, "End" + ], + [ + {"w": 1.25}, "Ctrl", + {"w": 1.25}, "Win", + {"w": 1.25}, "Alt", + {"a": 7, "w": 6.25}, "", + {"a": 6}, "Alt", "Fn", "Ctrl", + {"a": 7}, "←", "↓", "→" + ] +] diff --git a/jsonrpc.go b/jsonrpc.go index 4b9376bef..ac72d2b28 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -17,6 +17,7 @@ import ( "github.com/rs/zerolog" "github.com/jetkvm/kvm/internal/hidrpc" + "github.com/jetkvm/kvm/internal/keyboard" "github.com/jetkvm/kvm/internal/logging" "github.com/jetkvm/kvm/internal/usbgadget" "github.com/jetkvm/kvm/internal/utils" @@ -1334,6 +1335,9 @@ var rpcHandlers = map[string]RPCHandler{ "setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}}, "getKeyboardLayout": {Func: rpcGetKeyboardLayout}, "setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"layout"}}, + "getKeyboardLayouts": {Func: keyboard.RpcGetKeyboardLayouts}, + "getKeyboardLayoutData": {Func: keyboard.RpcGetKeyboardLayoutData, Params: []string{"id"}}, + "deleteKeyboardLayout": {Func: keyboard.RpcDeleteKeyboardLayout, Params: []string{"id"}}, "getKeyboardMacros": {Func: getKeyboardMacros}, "setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}}, "getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly}, diff --git a/scripts/audit_layouts.go b/scripts/audit_layouts.go new file mode 100644 index 000000000..f008a2cb9 --- /dev/null +++ b/scripts/audit_layouts.go @@ -0,0 +1,1017 @@ +//go:build ignore + +// audit-layouts validates built-in KLE layout files against their +// kbdlayout.info reference. Each layout's "kbdLayoutInfo" metadata field +// is used to derive the reference download URL; the reference is then parsed +// with the same keyboard parser so the comparison is semantic (charMap and +// legend layers), not textual. +// +// Usage: +// +// go run ./scripts/audit_layouts.go [flags] [locale ...] +// +// If no locale arguments are given, all built-in layouts are audited. +// +// Flags: +// +// -v Verbose: print per-scancode legend diffs and charMap deltas. +// -layouts Directory containing *.kle.json files. +// Default: internal/keyboard/layouts +// -cache Directory for caching downloaded reference JSON. +// Default: $TMPDIR/kbdlayout-cache +// -refresh Re-download references even if a cached copy exists. +// -scancodes Validate local HID scancodes against matching kbdlayout.info KLC data. +// +// Exit codes: +// +// 0 All audited layouts pass (missing entries = exit 1). +// 1 One or more layouts are missing characters or AltGr layers vs reference. +// 2 Usage / setup error. +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" + "unicode" + "unicode/utf8" + + "github.com/jetkvm/kvm/internal/keyboard" +) + +// --------------------------------------------------------------------------- +// Flags +// --------------------------------------------------------------------------- + +var ( + flagVerbose = flag.Bool("v", false, "verbose: print per-scancode and charMap diffs") + flagLayouts = flag.String("layouts", "internal/keyboard/layouts", "directory containing *.kle.json files") + flagCache = flag.String("cache", "", "cache directory for downloaded references (empty = use OS temp dir)") + flagRefresh = flag.Bool("refresh", false, "re-download references even if cached") + flagSC = flag.Bool("scancodes", false, "validate local HID scancodes against kbdlayout.info KLC data") +) + +func usage() { + fmt.Fprintf(os.Stderr, "Usage: go run ./scripts/audit_layouts.go [flags] [locale ...]\n\n") + flag.PrintDefaults() + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, "Examples:") + fmt.Fprintln(os.Stderr, " go run ./scripts/audit_layouts.go # audit all layouts") + fmt.Fprintln(os.Stderr, " go run ./scripts/audit_layouts.go -v de_DE # verbose audit of de_DE") + fmt.Fprintln(os.Stderr, " go run ./scripts/audit_layouts.go -refresh fr_BE # force re-download") + fmt.Fprintln(os.Stderr, " go run ./scripts/audit_layouts.go -scancodes en_US # validate scancodes against KLC data") +} + +// --------------------------------------------------------------------------- +// Raw KLE top-object metadata +// --------------------------------------------------------------------------- + +type kleTopObject struct { + KbdLayoutInfo string `json:"kbdLayoutInfo"` +} + +// extractMeta reads the first element of a KLE JSON array, which is an object +// containing board-level metadata. +func extractMeta(raw []byte) (kleTopObject, error) { + var outer []json.RawMessage + if err := json.Unmarshal(raw, &outer); err != nil { + return kleTopObject{}, fmt.Errorf("unmarshal outer array: %w", err) + } + if len(outer) == 0 { + return kleTopObject{}, fmt.Errorf("empty KLE array") + } + var meta kleTopObject + // Not all first elements are objects; ignore unmarshal errors here. + _ = json.Unmarshal(outer[0], &meta) + return meta, nil +} + +// --------------------------------------------------------------------------- +// Download / cache helpers +// --------------------------------------------------------------------------- + +func resolvedCacheDir() string { + if *flagCache != "" { + return *flagCache + } + return filepath.Join(os.TempDir(), "kbdlayout-cache") +} + +func fetchReference(infoURL string) ([]byte, error) { + // Derive download URL: https://kbdlayout.info/XXXX/ -> .../download/json + base := strings.TrimSuffix(infoURL, "/") + dlURL := base + "/download/json" + + // Cache key = last path segment of base URL. + parts := strings.Split(base, "/") + cacheKey := parts[len(parts)-1] + cacheFile := filepath.Join(resolvedCacheDir(), cacheKey+".json") + + if !*flagRefresh { + if data, err := os.ReadFile(cacheFile); err == nil { + return data, nil + } + } + + client := &http.Client{Timeout: 30 * time.Second} + req, err := http.NewRequest(http.MethodGet, dlURL, nil) + if err != nil { + return nil, fmt.Errorf("build request %s: %w", dlURL, err) + } + req.Header.Set("User-Agent", "audit-layouts/1.0 (jetkvm layout validation tool)") + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("GET %s: %w", dlURL, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GET %s: HTTP %d", dlURL, resp.StatusCode) + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read body from %s: %w", dlURL, err) + } + + // Best-effort cache write. + if err := os.MkdirAll(resolvedCacheDir(), 0o755); err == nil { + _ = os.WriteFile(cacheFile, data, 0o644) + } + return data, nil +} + +// reUnquotedKey matches unquoted JSON object keys, e.g. {x:1} or {a:6,y:0.5}. +var reUnquotedKey = regexp.MustCompile(`([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)(\s*:)`) + +var ( + // charMap entries for \\ and | commonly swap between 0x31 hidBackslash and 0x32 hidHashTilde + // across ANSI and ISO variants of an otherwise equivalent layout. + reISOANSIBackslashSwap = regexp.MustCompile(`^charMap "\\\\": ref=([0-9a-f]{2}):[0-9a-f]{2} local=([0-9a-f]{2}):[0-9a-f]{2}$`) + reISOANSIPipeSwap = regexp.MustCompile(`^charMap "\|": ref=([0-9a-f]{2}):[0-9a-f]{2} local=([0-9a-f]{2}):[0-9a-f]{2}$`) +) + +// normalizeKLEJSON converts KLE's JavaScript-style unquoted object keys to +// valid JSON. kbdlayout.info serves files with {x:1,y:0.5} syntax. +func normalizeKLEJSON(raw []byte) []byte { + return reUnquotedKey.ReplaceAll(raw, []byte(`${1}"${2}"${3}`)) +} + +type layers struct{ N, S, A, SA string } + +func sv(p *string) string { + if p == nil { + return "" + } + return *p +} + +func legendsByScancode(l *keyboard.KeyboardLayout) map[uint8]layers { + m := make(map[uint8]layers, len(l.Keys)) + for _, k := range l.Keys { + if k.Decal || !keyboard.ScancodeProducesText(k.Scancode) { + continue + } + m[k.Scancode] = layers{ + N: sv(k.Legends.Normal), + S: sv(k.Legends.Shift), + A: sv(k.Legends.AltGr), + SA: sv(k.Legends.ShiftAltGr), + } + } + return m +} + +func comboSig(c keyboard.HIDCombo) string { + if c.Prefix == nil { + return fmt.Sprintf("%02x:%02x", c.Scancode, c.Modifiers) + } + return fmt.Sprintf("%02x:%02x<%s", c.Scancode, c.Modifiers, comboSig(*c.Prefix)) +} + +// Audit result + +type diffKind int + +const ( + // diffMissing: reference has entry, local does not → FAIL. + diffMissing diffKind = iota + // diffScancode: KLC scancode expectation mismatch → FAIL. + diffScancode + // diffAllowed: known, explicitly allow-listed expected divergence. + diffAllowed + // diffLegend: legend value differs → WARN (informational). + diffLegend + // diffRemap: charMap entry exists in both but via different HID combo → WARN. + diffRemap + // diffExtra: local has entry reference does not → INFO only. + diffExtra +) + +type finding struct { + kind diffKind + subject string +} + +type auditResult struct { + locale string + findings []finding + err error +} + +// pass returns true when there are no missing-entry failures. +func (r auditResult) pass() bool { + if r.err != nil { + return false + } + for _, f := range r.findings { + if f.kind == diffMissing || f.kind == diffScancode { + return false + } + } + return true +} + +// warn returns true when the layout passes but has notable informational differences. +func (r auditResult) warn() bool { + if !r.pass() { + return false + } + for _, f := range r.findings { + if f.kind == diffLegend || f.kind == diffRemap { + return true + } + } + return false +} + +func allowReason(f finding) string { + if f.kind == diffExtra && strings.HasPrefix(f.subject, "sc 0x32: key absent in local") { + return "expected ISO/ANSI difference (ISO-specific key hidHashTilde 0x32)" + } + + if f.kind == diffLegend && (strings.HasPrefix(f.subject, "sc 0x31 ") || strings.HasPrefix(f.subject, "sc 0x32 ")) { + return "expected ISO/ANSI legend placement difference (hidBackslash 0x31/hidHashTilde 0x32)" + } + + if f.kind == diffRemap { + const deadKeySpaceMarker = ` local=2c:00<` + if idx := strings.Index(f.subject, deadKeySpaceMarker); idx >= 0 { + prefix := f.subject[:idx] + refIdx := strings.Index(prefix, "ref=") + if refIdx >= 0 { + refSig := prefix[refIdx+4:] + localSig := strings.TrimSuffix(f.subject[idx+len(deadKeySpaceMarker):], ">") + if refSig == localSig { + return "expected dead-key standalone output via dead-key+Space" + } + } + } + + if m := reISOANSIBackslashSwap.FindStringSubmatch(f.subject); len(m) == 3 { + if (m[1] == "31" && m[2] == "32") || (m[1] == "32" && m[2] == "31") { + return "expected ISO/ANSI remap for \\ to hidBackslash/hidHashTilde (0x31/0x32)" + } + } + if m := reISOANSIPipeSwap.FindStringSubmatch(f.subject); len(m) == 3 { + if (m[1] == "31" && m[2] == "32") || (m[1] == "32" && m[2] == "31") { + return "expected ISO/ANSI remap for | to hidBackslash/hidHashTilde (0x31/0x32)" + } + } + } + + return "" +} + +func applyAllowList(findings []finding) []finding { + out := make([]finding, 0, len(findings)) + for _, f := range findings { + reason := allowReason(f) + if reason == "" { + out = append(out, f) + continue + } + out = append(out, finding{ + kind: diffAllowed, + subject: fmt.Sprintf("%s [%s]", f.subject, reason), + }) + } + return out +} + +// --------------------------------------------------------------------------- +// Core audit logic +// --------------------------------------------------------------------------- + +func auditLocale(locale, localPath string) auditResult { + res := auditResult{locale: locale} + + localRaw, err := os.ReadFile(localPath) + if err != nil { + res.err = fmt.Errorf("read local file: %w", err) + return res + } + + meta, err := extractMeta(localRaw) + if err != nil { + res.err = fmt.Errorf("extract metadata: %w", err) + return res + } + if meta.KbdLayoutInfo == "" { + res.err = fmt.Errorf("no kbdLayoutInfo metadata in %s", localPath) + return res + } + + refRaw, err := fetchReference(meta.KbdLayoutInfo) + if err != nil { + res.err = fmt.Errorf("fetch reference: %w", err) + return res + } + + localLayout, err := keyboard.ParseKLE(localRaw, locale, "") + if err != nil { + res.err = fmt.Errorf("parse local layout: %w", err) + return res + } + refLayout, err := keyboard.ParseKLE(normalizeKLEJSON(refRaw), locale, "") + if err != nil { + res.err = fmt.Errorf("parse reference layout: %w", err) + return res + } + + localLeg := legendsByScancode(localLayout) + refLeg := legendsByScancode(refLayout) + + // Collect all scancodes present in reference. + refSCs := make([]int, 0, len(refLeg)) + for sc := range refLeg { + refSCs = append(refSCs, int(sc)) + } + sort.Ints(refSCs) + + for _, sci := range refSCs { + sc := uint8(sci) + ref := refLeg[sc] + local, ok := localLeg[sc] + if !ok { + // Entire key absent from local (e.g. ISO-only key on an ANSI layout). + // Treat as informational rather than a hard fail. + res.findings = append(res.findings, finding{ + kind: diffExtra, + subject: fmt.Sprintf("sc 0x%02X: key absent in local (ref N=%q S=%q A=%q SA=%q)", sc, ref.N, ref.S, ref.A, ref.SA), + }) + continue + } + + // Normal / Shift legend comparison — skip for control keys whose legends + // are just display names (Esc, ⏎, ⌫, etc.), not typed characters. + if !keyboard.IsControlScancode(sc) { + if ref.N != "" && ref.N != local.N { + res.findings = append(res.findings, finding{ + kind: diffLegend, + subject: fmt.Sprintf("sc 0x%02X normal: ref=%q local=%q", sc, ref.N, local.N), + }) + } + // Numpad shift legends are cosmetic in source data and vary by + // convention (e.g. + in both slots vs normal-only). Treat them as + // non-semantic to avoid noisy WARNs. + if !isNumpadHID(sc) && ref.S != "" && ref.S != local.S { + res.findings = append(res.findings, finding{ + kind: diffLegend, + subject: fmt.Sprintf("sc 0x%02X shift: ref=%q local=%q", sc, ref.S, local.S), + }) + } + } + + // AltGr layer: missing is a hard fail; wrong value is a warning. + if ref.A != "" && local.A == "" { + res.findings = append(res.findings, finding{ + kind: diffMissing, + subject: fmt.Sprintf("sc 0x%02X altgr: ref has %q but local is empty", sc, ref.A), + }) + } else if ref.A != "" && ref.A != local.A { + res.findings = append(res.findings, finding{ + kind: diffLegend, + subject: fmt.Sprintf("sc 0x%02X altgr: ref=%q local=%q", sc, ref.A, local.A), + }) + } + + // ShiftAltGr layer: same policy. + if ref.SA != "" && local.SA == "" { + res.findings = append(res.findings, finding{ + kind: diffMissing, + subject: fmt.Sprintf("sc 0x%02X shift+altgr: ref has %q but local is empty", sc, ref.SA), + }) + } else if ref.SA != "" && ref.SA != local.SA { + res.findings = append(res.findings, finding{ + kind: diffLegend, + subject: fmt.Sprintf("sc 0x%02X shift+altgr: ref=%q local=%q", sc, ref.SA, local.SA), + }) + } + + // Extra local AltGr layers (informational). + if ref.A == "" && local.A != "" { + res.findings = append(res.findings, finding{ + kind: diffExtra, + subject: fmt.Sprintf("sc 0x%02X altgr: local has %q but ref is empty", sc, local.A), + }) + } + if ref.SA == "" && local.SA != "" { + res.findings = append(res.findings, finding{ + kind: diffExtra, + subject: fmt.Sprintf("sc 0x%02X shift+altgr: local has %q but ref is empty", sc, local.SA), + }) + } + } + + // charMap: check that every character the reference can type is reachable locally. + // Missing = FAIL; different combo = WARN (character is still reachable). + for ch, refCombo := range refLayout.CharMap { + localCombo, ok := localLayout.CharMap[ch] + if !ok { + res.findings = append(res.findings, finding{ + kind: diffMissing, + subject: fmt.Sprintf("charMap missing %q (ref combo %s)", ch, comboSig(refCombo)), + }) + } else if comboSig(localCombo) != comboSig(refCombo) { + res.findings = append(res.findings, finding{ + kind: diffRemap, + subject: fmt.Sprintf("charMap %q: ref=%s local=%s", ch, comboSig(refCombo), comboSig(localCombo)), + }) + } + } + + // Extra chars in local (informational). + for ch := range localLayout.CharMap { + if _, ok := refLayout.CharMap[ch]; !ok { + res.findings = append(res.findings, finding{ + kind: diffExtra, + subject: fmt.Sprintf("charMap extra %q (not in reference)", ch), + }) + } + } + + if *flagSC { + scFindings, err := auditKLCScancodes(localRaw, localLayout) + if err != nil { + res.err = fmt.Errorf("KLC scancode audit: %w", err) + return res + } + res.findings = append(res.findings, scFindings...) + } + + res.findings = applyAllowList(res.findings) + + return res +} + +// KLC scancode audit + +// The KLC format uses PS/2 scancodes, so we need to map those to HID usage codes for comparison. +// These maps are based on the standard PS/2 set 1 scancodes and their common E0 extensions, as +// documented in various sources including the Linux kernel source and USB HID usage tables. +var ps2ToHID = map[uint8]uint8{ + 0x01: 0x29, // Esc + 0x02: 0x1E, // 1 + 0x03: 0x1F, // 2 + 0x04: 0x20, // 3 + 0x05: 0x21, // 4 + 0x06: 0x22, // 5 + 0x07: 0x23, // 6 + 0x08: 0x24, // 7 + 0x09: 0x25, // 8 + 0x0A: 0x26, // 9 + 0x0B: 0x27, // 0 + 0x0C: 0x2D, // Minus + 0x0D: 0x2E, // Equal + 0x0E: 0x2A, // Backspace + 0x0F: 0x2B, // Tab + 0x10: 0x14, // Q + 0x11: 0x1A, // W + 0x12: 0x08, // E + 0x13: 0x15, // R + 0x14: 0x17, // T + 0x15: 0x1C, // Y + 0x16: 0x18, // U + 0x17: 0x0C, // I + 0x18: 0x12, // O + 0x19: 0x13, // P + 0x1A: 0x2F, // LeftBracket + 0x1B: 0x30, // RightBracket + 0x1C: 0x28, // Enter + 0x1D: 0xE0, // LCtrl + 0x1E: 0x04, // A + 0x1F: 0x16, // S + 0x20: 0x07, // D + 0x21: 0x09, // F + 0x22: 0x0A, // G + 0x23: 0x0B, // H + 0x24: 0x0D, // J + 0x25: 0x0E, // K + 0x26: 0x0F, // L + 0x27: 0x33, // Semicolon + 0x28: 0x34, // Quote + 0x29: 0x35, // Grave + 0x2A: 0xE1, // LShift + 0x2B: 0x31, // Backslash + 0x2C: 0x1D, // Z + 0x2D: 0x1B, // X + 0x2E: 0x06, // C + 0x2F: 0x19, // V + 0x30: 0x05, // B + 0x31: 0x11, // N + 0x32: 0x10, // M + 0x33: 0x36, // Comma + 0x34: 0x37, // Period + 0x35: 0x38, // Slash + 0x36: 0xE5, // RShift + 0x37: 0x55, // KP * + 0x38: 0xE2, // LAlt + 0x39: 0x2C, // Space + 0x3A: 0x39, // CapsLock + 0x3B: 0x3A, // F1 + 0x3C: 0x3B, // F2 + 0x3D: 0x3C, // F3 + 0x3E: 0x3D, // F4 + 0x3F: 0x3E, // F5 + 0x40: 0x3F, // F6 + 0x41: 0x40, // F7 + 0x42: 0x41, // F8 + 0x43: 0x42, // F9 + 0x44: 0x43, // F10 + 0x45: 0x53, // NumLock + 0x46: 0x47, // ScrollLock + 0x47: 0x5F, // KP 7 + 0x48: 0x60, // KP 8 + 0x49: 0x61, // KP 9 + 0x4A: 0x56, // KP - + 0x4B: 0x5C, // KP 4 + 0x4C: 0x5D, // KP 5 + 0x4D: 0x5E, // KP 6 + 0x4E: 0x57, // KP + + 0x4F: 0x59, // KP 1 + 0x50: 0x5A, // KP 2 + 0x51: 0x5B, // KP 3 + 0x52: 0x62, // KP 0 + 0x53: 0x63, // KP . + 0x56: 0x64, // NonUSBackslash + 0x57: 0x44, // F11 + 0x58: 0x45, // F12 +} + +// E0-extended scancodes: the base scancode is 0xE0, and the actual scancode is the second byte. +var ps2E0ToHID = map[uint8]uint8{ + 0x1C: 0x58, // KP Enter + 0x1D: 0xE4, // RCtrl + 0x35: 0x54, // KP / + 0x37: 0x46, // PrintScreen + 0x38: 0xE6, // RAlt (AltGr) + 0x47: 0x4A, // Home + 0x48: 0x52, // ArrowUp + 0x49: 0x4B, // PageUp + 0x4B: 0x50, // ArrowLeft + 0x4D: 0x4F, // ArrowRight + 0x4F: 0x4D, // End + 0x50: 0x51, // ArrowDown + 0x51: 0x4E, // PageDown + 0x52: 0x49, // Insert + 0x53: 0x4C, // Delete + 0x5B: 0xE3, // LGUI + 0x5C: 0xE7, // RGUI + 0x5D: 0x65, // Application +} + +func parseKLCScancodeToHID(scHex string) (uint8, bool) { + scHex = strings.TrimSpace(strings.ToUpper(scHex)) + if len(scHex) == 2 { + scVal, err := strconv.ParseUint(scHex, 16, 8) + if err != nil { + return 0, false + } + hid, ok := ps2ToHID[uint8(scVal)] + return hid, ok + } + if len(scHex) == 4 && strings.HasPrefix(scHex, "E0") { + extVal, err := strconv.ParseUint(scHex[2:], 16, 8) + if err != nil { + return 0, false + } + hid, ok := ps2E0ToHID[uint8(extVal)] + return hid, ok + } + return 0, false +} + +func fetchKLC(infoURL string) ([]byte, error) { + base := strings.TrimSuffix(infoURL, "/") + parts := strings.Split(base, "/") + cacheKey := parts[len(parts)-1] + cacheFile := filepath.Join(resolvedCacheDir(), cacheKey+".klc") + + if !*flagRefresh { + if data, err := os.ReadFile(cacheFile); err == nil { + return data, nil + } + } + + dlURL := base + "/download/klc" + client := &http.Client{Timeout: 30 * time.Second} + req, err := http.NewRequest(http.MethodGet, dlURL, nil) + if err != nil { + return nil, fmt.Errorf("build request %s: %w", dlURL, err) + } + req.Header.Set("User-Agent", "audit-layouts/1.0 (jetkvm layout scancode validator)") + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("GET %s: %w", dlURL, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("GET %s: HTTP %d", dlURL, resp.StatusCode) + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read body from %s: %w", dlURL, err) + } + + if err := os.MkdirAll(resolvedCacheDir(), 0o755); err == nil { + _ = os.WriteFile(cacheFile, data, 0o644) + } + return data, nil +} + +func parseKLCCharToHID(data []byte) (map[rune]uint8, map[rune]bool, map[uint8]bool, error) { + charToHID := make(map[rune]uint8) + ambiguous := make(map[rune]bool) + charToFirstHID := make(map[rune]uint8) + expectedHIDs := make(map[uint8]bool) + + raw := normalizeKLCEncoding(data) + inLayout := false + scanner := bufio.NewScanner(bytes.NewReader(raw)) + for scanner.Scan() { + line := scanner.Text() + if idx := strings.Index(line, "//"); idx >= 0 { + line = line[:idx] + } + line = strings.TrimSpace(line) + if line == "" { + continue + } + + upper := strings.ToUpper(line) + if upper == "LAYOUT" { + inLayout = true + continue + } + if inLayout && isKLCSectionKeyword(upper) && upper != "LAYOUT" { + inLayout = false + continue + } + if !inLayout { + continue + } + + fields := strings.Fields(line) + if len(fields) < 4 { + continue + } + + hid, ok := parseKLCScancodeToHID(fields[0]) + if !ok { + continue + } + expectedHIDs[hid] = true + + for _, col := range fields[3:] { + if col == "-1" || strings.HasSuffix(col, "@") { + continue + } + cp, err := strconv.ParseUint(col, 16, 32) + if err != nil { + continue + } + r := rune(cp) + if !isPrintableRune(r) { + continue + } + if prev, seen := charToFirstHID[r]; seen { + if prev != hid { + ambiguous[r] = true + delete(charToHID, r) + } + continue + } + charToFirstHID[r] = hid + if !ambiguous[r] { + charToHID[r] = hid + } + } + } + if err := scanner.Err(); err != nil { + return nil, nil, nil, err + } + return charToHID, ambiguous, expectedHIDs, nil +} + +func normalizeKLCEncoding(data []byte) []byte { + if len(data) >= 2 && data[0] == 0xFF && data[1] == 0xFE { + u16 := make([]uint16, (len(data)-2)/2) + for i := range u16 { + u16[i] = uint16(data[2+i*2]) | uint16(data[2+i*2+1])<<8 + } + var out bytes.Buffer + for _, u := range u16 { + var tmp [utf8.UTFMax]byte + n := utf8.EncodeRune(tmp[:], rune(u)) + out.Write(tmp[:n]) + } + return out.Bytes() + } + if len(data) >= 2 && data[0] == 0xFE && data[1] == 0xFF { + u16 := make([]uint16, (len(data)-2)/2) + for i := range u16 { + u16[i] = uint16(data[2+i*2])<<8 | uint16(data[2+i*2+1]) + } + var out bytes.Buffer + for _, u := range u16 { + var tmp [utf8.UTFMax]byte + n := utf8.EncodeRune(tmp[:], rune(u)) + out.Write(tmp[:n]) + } + return out.Bytes() + } + return data +} + +func isKLCSectionKeyword(s string) bool { + switch s { + case "KBD", "COPYRIGHT", "COMPANY", "LOCALENAME", "LOCALEID", + "VERSION", "SHIFTSTATE", "ATTRIBUTES", "LAYOUT", + "DEADKEY", "KEYNAME", "KEYNAME_EXT", "KEYNAME_DEAD", + "DESCRIPTIONS", "LANGUAGENAMES", "ENDKBD": + return true + } + return false +} + +func isPrintableRune(r rune) bool { + if r == utf8.RuneError || r == 0 { + return false + } + if unicode.IsControl(r) || !unicode.IsPrint(r) { + return false + } + return true +} + +func isNumpadHID(sc uint8) bool { + return sc >= 0x53 && sc <= 0x63 +} + +// Numpad glyphs often duplicate main-cluster characters in KLC char maps. +// For those glyphs, char->HID lookups are ambiguous and should not produce +// strict scancode mismatches when checking a numpad key. +func isAmbiguousNumpadRune(r rune) bool { + switch r { + case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', ',', '+', '-', '*', '/': + return true + } + return false +} + +func firstRune(s string) rune { + for _, r := range s { + return r + } + return 0 +} + +const ( + hidBackslash uint8 = 0x31 + hidHashTilde uint8 = 0x32 + hidISOKey uint8 = 0x64 +) + +func hasEquivalentISOANSIKey(set map[uint8]bool, hid uint8) bool { + if hid == hidBackslash || hid == hidHashTilde || hid == hidISOKey { + return set[hidBackslash] || set[hidHashTilde] || set[hidISOKey] + } + return set[hid] +} + +func auditKLCScancodes(localRaw []byte, localLayout *keyboard.KeyboardLayout) ([]finding, error) { + meta, err := extractMeta(localRaw) + if err != nil { + return nil, err + } + if meta.KbdLayoutInfo == "" { + return nil, fmt.Errorf("no kbdLayoutInfo metadata") + } + klcRaw, err := fetchKLC(meta.KbdLayoutInfo) + if err != nil { + return nil, err + } + charToHID, _, expectedHIDs, err := parseKLCCharToHID(klcRaw) + if err != nil { + return nil, err + } + + findings := make([]finding, 0) + localHIDs := make(map[uint8]bool) + for _, key := range localLayout.Keys { + if key.Decal { + continue + } + if key.Scancode != 0 { + localHIDs[key.Scancode] = true + } + deadSlots := map[string]bool{} + for _, slot := range key.DeadLegends { + deadSlots[slot] = true + } + type slot struct { + name string + ptr *string + } + for _, sl := range []slot{ + {name: "normal", ptr: key.Legends.Normal}, + {name: "shift", ptr: key.Legends.Shift}, + {name: "altgr", ptr: key.Legends.AltGr}, + {name: "shift-altgr", ptr: key.Legends.ShiftAltGr}, + } { + if sl.ptr == nil || deadSlots[sl.name] { + continue + } + legend := *sl.ptr + if len([]rune(legend)) != 1 { + continue + } + r := firstRune(legend) + if !isPrintableRune(r) { + continue + } + expected, ok := charToHID[r] + if !ok { + continue + } + if isNumpadHID(key.Scancode) && !isNumpadHID(expected) && isAmbiguousNumpadRune(r) { + continue + } + if (key.Scancode == hidBackslash || key.Scancode == hidHashTilde || key.Scancode == hidISOKey) && + (expected == hidBackslash || expected == hidHashTilde || expected == hidISOKey) { + continue + } + if key.Scancode != expected { + findings = append(findings, finding{ + kind: diffScancode, + subject: fmt.Sprintf("KLC scancode mismatch: %q (U+%04X) at (%.2f,%.2f): local=0x%02X expected=0x%02X", legend, r, key.X, key.Y, key.Scancode, expected), + }) + } + } + } + + for expected := range expectedHIDs { + if hasEquivalentISOANSIKey(localHIDs, expected) { + continue + } + findings = append(findings, finding{ + kind: diffScancode, + subject: fmt.Sprintf("KLC expected key missing in local layout: HID 0x%02X", expected), + }) + } + return findings, nil +} + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- + +func main() { + flag.Usage = usage + flag.Parse() + + layoutDir := *flagLayouts + + // Resolve layout directory relative to working dir or repo root. + if _, err := os.Stat(layoutDir); os.IsNotExist(err) { + wd, _ := os.Getwd() + for dir := wd; dir != filepath.Dir(dir); dir = filepath.Dir(dir) { + candidate := filepath.Join(dir, *flagLayouts) + if _, err := os.Stat(candidate); err == nil { + layoutDir = candidate + break + } + } + } + if _, err := os.Stat(layoutDir); os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "error: layouts directory not found: %s\n", *flagLayouts) + os.Exit(2) + } + + // Build locale -> file map. + entries, err := filepath.Glob(filepath.Join(layoutDir, "*.kle.json")) + if err != nil || len(entries) == 0 { + fmt.Fprintf(os.Stderr, "error: no *.kle.json files in %s\n", layoutDir) + os.Exit(2) + } + fileByLocale := make(map[string]string, len(entries)) + for _, path := range entries { + base := filepath.Base(path) + locale := strings.TrimSuffix(base, ".kle.json") + fileByLocale[locale] = path + } + + // Determine which locales to audit. + requested := flag.Args() + var locales []string + if len(requested) > 0 { + for _, l := range requested { + l = strings.TrimSuffix(l, ".kle.json") + if _, ok := fileByLocale[l]; !ok { + fmt.Fprintf(os.Stderr, "error: unknown locale %q (no matching .kle.json)\n", l) + os.Exit(2) + } + locales = append(locales, l) + } + } else { + for l := range fileByLocale { + locales = append(locales, l) + } + sort.Strings(locales) + } + + // Run audits. + passed, warned, failed := 0, 0, 0 + for _, locale := range locales { + result := auditLocale(locale, fileByLocale[locale]) + + if result.err != nil { + fmt.Printf("ERROR %s: %v\n", locale, result.err) + failed++ + continue + } + + switch { + case !result.pass(): + fmt.Printf("FAIL %s\n", locale) + failed++ + case result.warn(): + fmt.Printf("WARN %s\n", locale) + warned++ + default: + // Count extras for the pass line. + extras := 0 + for _, f := range result.findings { + if f.kind == diffExtra { + extras++ + } + } + if extras > 0 { + fmt.Printf("PASS %s (+%d extra vs reference)\n", locale, extras) + } else { + fmt.Printf("PASS %s\n", locale) + } + passed++ + } + + if *flagVerbose && len(result.findings) > 0 { + groups := map[diffKind][]string{} + for _, f := range result.findings { + groups[f.kind] = append(groups[f.kind], f.subject) + } + printGroup := func(label string, kind diffKind) { + items := groups[kind] + if len(items) == 0 { + return + } + sort.Strings(items) + fmt.Printf(" [%s] %d finding(s):\n", label, len(items)) + for _, s := range items { + fmt.Printf(" %s\n", s) + } + } + printGroup("FAIL: missing in local", diffMissing) + printGroup("FAIL: KLC scancode mismatch", diffScancode) + printGroup("allow: expected ISO/ANSI difference", diffAllowed) + printGroup("warn: legend differs", diffLegend) + printGroup("warn: charMap remap", diffRemap) + printGroup("info: extra in local", diffExtra) + fmt.Println() + } + } + + fmt.Printf("\n%d passed, %d warned, %d failed\n", passed, warned, failed) + if failed > 0 { + os.Exit(1) + } +} diff --git a/scripts/build_cgo.sh b/scripts/build_cgo.sh index 868c59e04..0330abd4a 100755 --- a/scripts/build_cgo.sh +++ b/scripts/build_cgo.sh @@ -23,6 +23,7 @@ msg_info "▶ Generating UI index" ./ui_index.gen.sh msg_info "▶ Building native library" +git config --global --add safe.directory "/build/internal/*" VERBOSE=1 cmake -B "${BUILD_DIR}" \ -DCMAKE_SYSTEM_PROCESSOR=armv7l \ -DCMAKE_SYSTEM_NAME=Linux \ diff --git a/scripts/validate_layout.go b/scripts/validate_layout.go new file mode 100644 index 000000000..d427edcb9 --- /dev/null +++ b/scripts/validate_layout.go @@ -0,0 +1,125 @@ +//go:build ignore + +// validate_layout.go — validates a KLE JSON file through the keyboard parser. +// +// Usage: +// go run scripts/validate_layout.go [layout-id] +// +// Examples: +// go run scripts/validate_layout.go my-layout.kle.json +// go run scripts/validate_layout.go my-layout.kle.json "custom-layout" +// +// Exits 0 on success, 1 on validation errors. +// Reports key count, charMap size, dead key compositions, and any issues found. + +package main + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/jetkvm/kvm/internal/keyboard" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintf(os.Stderr, "Usage: go run scripts/validate_layout.go [layout-id]\n") + os.Exit(1) + } + + path := os.Args[1] + id := "validate" + if len(os.Args) >= 3 { + id = os.Args[2] + } + + data, err := os.ReadFile(path) + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading file: %v\n", err) + os.Exit(1) + } + + layout, err := keyboard.ParseKLE(data, id, "") + if err != nil { + fmt.Fprintf(os.Stderr, "Validation FAILED: %v\n", err) + os.Exit(1) + } + + // Count dead key compositions + deadKeyCount := 0 + for _, combo := range layout.CharMap { + if combo.Prefix != nil { + deadKeyCount++ + } + } + + // Count keys with scancodes vs unknown + mapped := 0 + unmapped := 0 + for _, k := range layout.Keys { + if k.Scancode != 0 { + mapped++ + } else { + unmapped++ + } + } + + fmt.Printf("Layout: %s\n", layout.Name) + if layout.Author != "" { + fmt.Printf("Author: %s\n", layout.Author) + } + fmt.Printf("ID: %s\n", layout.ID) + fmt.Printf("Board size: %.0f x %.0f units\n", layout.BoardW, layout.BoardH) + fmt.Printf("Keys: %d total (%d mapped to HID scancodes, %d unmapped)\n", len(layout.Keys), mapped, unmapped) + fmt.Printf("CharMap: %d entries (%d with dead key prefix)\n", len(layout.CharMap), deadKeyCount) + + // Warn about potential issues + warnings := 0 + if unmapped > 5 { + fmt.Printf("WARNING: %d keys have no HID scancode — they may be in non-standard positions\n", unmapped) + warnings++ + } + + pct := float64(mapped) / float64(len(layout.Keys)) * 100 + if pct < 80 { + fmt.Printf("WARNING: Only %.0f%% of keys mapped — layout may have unusual positioning\n", pct) + warnings++ + } + + if len(layout.CharMap) < 30 { + fmt.Printf("WARNING: CharMap only has %d entries — expected 60+ for a full layout\n", len(layout.CharMap)) + warnings++ + } + + // Pretty-print a sample of the charMap for spot-checking + fmt.Printf("\nSample charMap entries:\n") + count := 0 + for char, combo := range layout.CharMap { + if count >= 10 { + fmt.Printf(" ... and %d more\n", len(layout.CharMap)-10) + break + } + if combo.Prefix != nil { + fmt.Printf(" %q → scancode=0x%02X mod=0x%02X (dead prefix: scancode=0x%02X mod=0x%02X)\n", + char, combo.Scancode, combo.Modifiers, combo.Prefix.Scancode, combo.Prefix.Modifiers) + } else { + fmt.Printf(" %q → scancode=0x%02X mod=0x%02X\n", char, combo.Scancode, combo.Modifiers) + } + count++ + } + + // Output the full JSON for inspection if requested + if os.Getenv("VERBOSE") == "1" { + fmt.Printf("\nFull KeyboardLayout JSON:\n") + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + _ = enc.Encode(layout) + } + + if warnings > 0 { + fmt.Printf("\nValidation passed with %d warning(s)\n", warnings) + } else { + fmt.Printf("\nValidation passed\n") + } +} diff --git a/ui/.husky/pre-commit b/ui/.husky/pre-commit index 61534f1af..16d2379f3 100644 --- a/ui/.husky/pre-commit +++ b/ui/.husky/pre-commit @@ -1 +1,9 @@ +#!/bin/sh + +# Skip if lint-staged isn't available (e.g. Windows Git GUI running outside WSL) +if ! command -v npx >/dev/null 2>&1; then + echo "husky: npx not found, skipping pre-commit hook (run lint manually)" + exit 0 +fi + cd ui && npx lint-staged diff --git a/ui/.markdownlint.json b/ui/.markdownlint.json new file mode 100644 index 000000000..4baf81e86 --- /dev/null +++ b/ui/.markdownlint.json @@ -0,0 +1,4 @@ +{ + "MD013": false, + "MD060": false +} \ No newline at end of file diff --git a/ui/.oxlintrc.json b/ui/.oxlintrc.json index 750eaf13a..398757ecc 100644 --- a/ui/.oxlintrc.json +++ b/ui/.oxlintrc.json @@ -11,14 +11,7 @@ "typescript/no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }], "typescript/no-floating-promises": "off", "typescript/restrict-template-expressions": "warn", - "typescript/no-base-to-string": "warn", - "import/order": [ - "error", - { - "groups": ["builtin", "external", "internal", "parent", "sibling"], - "newlines-between": "ignore" - } - ] + "typescript/no-base-to-string": "warn" }, "settings": { "react": { diff --git a/ui/e2e/global-setup.ts b/ui/e2e/global-setup.ts index b07495812..1dc40a9de 100644 --- a/ui/e2e/global-setup.ts +++ b/ui/e2e/global-setup.ts @@ -8,12 +8,20 @@ import { restartAppViaSSH, saveSSHDevState, restoreSSHDevState, + backupOriginalConfigIfNeeded, SSH_OPTS, } from "./helpers"; const execAsync = promisify(exec); export default async function globalSetup() { + // Save the user's pre-test config before anything else touches it. + try { + await backupOriginalConfigIfNeeded(); + } catch (err) { + console.error("[global-setup] Could not back up original config: ", err); + } + const binaryPath = process.env.BASELINE_BINARY_PATH; if (!binaryPath) { console.log("[global-setup] BASELINE_BINARY_PATH not set, skipping deployment."); diff --git a/ui/e2e/global-teardown.ts b/ui/e2e/global-teardown.ts index 479dbfca9..9d2e2f452 100644 --- a/ui/e2e/global-teardown.ts +++ b/ui/e2e/global-teardown.ts @@ -6,6 +6,7 @@ import { restartAppViaSSH, saveSSHDevState, restoreSSHDevState, + restoreOriginalConfig, } from "./helpers"; export default async function globalTeardown() { @@ -35,15 +36,20 @@ export default async function globalTeardown() { } } - console.log("[global-teardown] Resetting device to clean state..."); try { + if (await restoreOriginalConfig()) { + await restartAppViaSSH(); + console.log("[global-teardown] Original device config restored."); + return; + } + console.log("[global-teardown] No original config backup; resetting to clean state..."); const saved = await saveSSHDevState(); await resetConfigViaSSH(); await restoreSSHDevState(saved); await restartAppViaSSH(); console.log("[global-teardown] Device reset complete."); } catch { - console.log("[global-teardown] Device reset failed (best-effort)."); + console.log("[global-teardown] Device cleanup failed (best-effort)."); } } diff --git a/ui/e2e/helpers.ts b/ui/e2e/helpers.ts index 1e6f38725..f80613f39 100644 --- a/ui/e2e/helpers.ts +++ b/ui/e2e/helpers.ts @@ -720,6 +720,53 @@ export async function resetConfigViaSSH(): Promise { await sshExec("sync"); } +// On-device backup of the user's pre-test kvm_config.json. Saved once at the +// start of the suite and consumed (mv'd back) at teardown so the device is left +// with the same configuration it had before tests ran. /userdata persists +// across reboots, which the OTA tests rely on. +const ORIGINAL_CONFIG_DEVICE_PATH = "/userdata/kvm_config.json"; +const ORIGINAL_CONFIG_BACKUP_PATH = "/userdata/kvm_config.original.json"; + +async function deviceFileExists(path: string): Promise { + const out = await sshExec(`test -f ${path} && echo 1 || echo 0`, true); + return out.trim() === "1"; +} + +/** + * Save the user's current kvm_config.json so it can be restored after the + * e2e suite finishes. Idempotent: if a backup already exists from a prior + * interrupted run, it is left alone so the original is never overwritten by + * a test-polluted config. + */ +export async function backupOriginalConfigIfNeeded(): Promise { + if (await deviceFileExists(ORIGINAL_CONFIG_BACKUP_PATH)) { + console.log( + `[e2e] Original config backup already present at ${ORIGINAL_CONFIG_BACKUP_PATH}, skipping save.`, + ); + return; + } + if (!(await deviceFileExists(ORIGINAL_CONFIG_DEVICE_PATH))) { + console.log("[e2e] No current kvm_config.json on device; nothing to back up."); + return; + } + await sshExec(`cp ${ORIGINAL_CONFIG_DEVICE_PATH} ${ORIGINAL_CONFIG_BACKUP_PATH} && sync`); + console.log(`[e2e] Saved original kvm_config.json to ${ORIGINAL_CONFIG_BACKUP_PATH}.`); +} + +/** + * Restore the user's original kvm_config.json (saved by + * backupOriginalConfigIfNeeded). Returns true if a backup was found and + * restored. The backup file is consumed (mv'd back into place). + */ +export async function restoreOriginalConfig(): Promise { + if (!(await deviceFileExists(ORIGINAL_CONFIG_BACKUP_PATH))) { + return false; + } + await sshExec(`mv ${ORIGINAL_CONFIG_BACKUP_PATH} ${ORIGINAL_CONFIG_DEVICE_PATH} && sync`); + console.log(`[e2e] Restored original kvm_config.json from ${ORIGINAL_CONFIG_BACKUP_PATH}.`); + return true; +} + export interface SSHDevState { sshKey: string; devModeEnabled: boolean; @@ -1361,3 +1408,34 @@ declare global { }; } } + +/** Navigate to the device session page and wait for connectivity. */ +export async function goToSession(page: Page) { + await page.goto("/"); + await waitForWebRTCReady(page); +} + +/** Navigate to the keyboard settings page. */ +export async function goToKeyboardSettings(page: Page) { + await page.goto("/settings/keyboard"); + await page.waitForLoadState("networkidle"); +} + +/** Get the list of layouts via JSON-RPC. */ +export async function getLayouts(page: Page) { + return callJsonRpc(page, "getKeyboardLayouts") as Promise< + Array<{ id: string; name: string; builtin: boolean }> + >; +} + +/** Get a specific layout's full data via JSON-RPC. */ +export async function getLayoutData(page: Page, id: string) { + return callJsonRpc(page, "getKeyboardLayoutData", { id }) as Promise<{ + id: string; + name: string; + keys: Array<{ scancode: number; legends: Record }>; + charMap: Record; + boardW: number; + boardH: number; + }>; +} diff --git a/ui/e2e/keyboard-layouts.spec.ts b/ui/e2e/keyboard-layouts.spec.ts new file mode 100644 index 000000000..33e8d1fd4 --- /dev/null +++ b/ui/e2e/keyboard-layouts.spec.ts @@ -0,0 +1,197 @@ +/** + * Keyboard layout management e2e tests (no host required). + * + * Covers the layout administration surface: + * - getKeyboardLayouts returns all built-ins + * - setKeyboardLayout / getKeyboardLayout round-trip persists + * - POST /keyboard/upload installs a custom KLE; getKeyboardLayouts + * surfaces it; deleteKeyboardLayout removes it + * - deleteKeyboardLayout refuses built-ins + * - settings page renders the uploaded layout with delete/preview buttons + * + * Run with: + * JETKVM_URL=http:// npx playwright test keyboard-layouts --project=ui + */ +import { readFileSync } from "fs"; +import { resolve } from "path"; +import { fileURLToPath } from "url"; +import { test, expect, type Page } from "@playwright/test"; +import { callJsonRpc, ensureLocalAuthMode, goToSession, sshExec } from "./helpers"; + +const TEST_LAYOUT_ID = "e2e-test-layout"; + +// Use a known-good KLE fixture that already passes the Go parser's +// validation (≥10 keys, position-based scancode inference covers ≥~75%). +// internal/keyboard/testdata/ansi_60.kle.json is what the Go unit tests +// (TestANSI60Parse, etc.) use; reading it at runtime keeps the test +// fixture and the parser tests in lock-step automatically. +const ANSI_60_KLE = readFileSync( + resolve( + fileURLToPath(new URL(".", import.meta.url)), + "../../internal/keyboard/testdata/ansi_60.kle.json", + ), + "utf8", +); + +interface LayoutMeta { + id: string; + name: string; + builtin?: boolean; +} + +interface UploadResponse { + id: string; + name: string; + keyCount: number; + warnings?: string[]; +} + +async function uploadTestLayout( + page: Page, + name?: string, + replaceId?: string, +): Promise { + // Upload via the page so the auth cookie set by goToSession travels with the + // request — POST /keyboard/upload is gated behind the same session as the UI. + return page.evaluate( + async ({ body, name: n, replaceId: r }) => { + const params = new URLSearchParams(); + if (n) params.set("name", n); + if (r) params.set("id", r); + const qs = params.toString(); + const url = `/keyboard/upload${qs ? "?" + qs : ""}`; + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); + if (!res.ok) { + throw new Error(`upload failed: ${res.status} ${await res.text()}`); + } + return (await res.json()) as { + id: string; + name: string; + keyCount: number; + warnings?: string[]; + }; + }, + { body: ANSI_60_KLE, name, replaceId }, + ); +} + +async function getLayouts(page: Page): Promise { + return (await callJsonRpc(page, "getKeyboardLayouts")) as LayoutMeta[]; +} + +/** Best-effort cleanup: try to delete via RPC, then by SSH if reachable. */ +async function deleteTestLayoutQuietly(page: Page): Promise { + try { + await callJsonRpc(page, "deleteKeyboardLayout", { id: TEST_LAYOUT_ID }); + } catch { + /* not present */ + } + // Belt-and-braces: clean the file directly so a partial test run can't + // poison the next iteration. + await sshExec(`rm -f /userdata/kvm_layouts/${TEST_LAYOUT_ID}.layout.json`, true).catch(() => {}); +} + +test.describe.configure({ mode: "serial" }); + +let sharedPage: Page; + +test.beforeAll(async ({ browser }) => { + test.setTimeout(60_000); + sharedPage = await browser.newPage(); + await ensureLocalAuthMode(sharedPage, { mode: "noPassword" }); + await goToSession(sharedPage); + await deleteTestLayoutQuietly(sharedPage); +}); + +test.afterAll(async () => { + await deleteTestLayoutQuietly(sharedPage); + await sharedPage?.close(); +}); + +test.describe("layouts: built-ins", () => { + test("getKeyboardLayouts surfaces a meaningful set of built-ins", async () => { + const layouts = await getLayouts(sharedPage); + expect(Array.isArray(layouts)).toBe(true); + expect(layouts.length, "should ship multiple layouts").toBeGreaterThan(5); + + // Spot-check the layouts the paste tests rely on. + const ids = layouts.map(l => l.id); + for (const id of ["en-US", "de-DE", "fr-FR"]) { + expect(ids, `built-in ${id}`).toContain(id); + } + }); + + test("deleteKeyboardLayout refuses built-in layouts", async () => { + await expect(callJsonRpc(sharedPage, "deleteKeyboardLayout", { id: "en-US" })).rejects.toThrow( + /cannot delete built-in/i, + ); + }); +}); + +test.describe("layouts: setKeyboardLayout persistence", () => { + test("setKeyboardLayout to a built-in is reflected by getKeyboardLayout", async () => { + await callJsonRpc(sharedPage, "setKeyboardLayout", { layout: "de-DE" }); + const current = await callJsonRpc(sharedPage, "getKeyboardLayout"); + expect(current).toBe("de-DE"); + + // Restore to en-US so other test suites aren't surprised. + await callJsonRpc(sharedPage, "setKeyboardLayout", { layout: "en-US" }); + }); +}); + +test.describe("layouts: custom KLE upload + delete", () => { + test("upload installs the layout, getKeyboardLayouts lists it, delete removes it", async () => { + // Upload — Go assigns an ID derived from the name when no id query is set. + // To get a known ID we replace into TEST_LAYOUT_ID. + const result = await uploadTestLayout(sharedPage, "E2E Test Layout", TEST_LAYOUT_ID); + expect(result.id).toBe(TEST_LAYOUT_ID); + expect(result.keyCount).toBeGreaterThan(0); + + // The list should now include it. + let layouts = await getLayouts(sharedPage); + expect( + layouts.find(l => l.id === TEST_LAYOUT_ID), + "uploaded layout in list", + ).toBeDefined(); + + // Layout data is fetchable. + const data = (await callJsonRpc(sharedPage, "getKeyboardLayoutData", { + id: TEST_LAYOUT_ID, + })) as { id: string; charMap: Record }; + expect(data.id).toBe(TEST_LAYOUT_ID); + expect(data.charMap["a"], "uploaded layout has 'a' in charMap").toBeDefined(); + + // Delete and verify it's gone. + await callJsonRpc(sharedPage, "deleteKeyboardLayout", { id: TEST_LAYOUT_ID }); + layouts = await getLayouts(sharedPage); + expect( + layouts.find(l => l.id === TEST_LAYOUT_ID), + "layout removed", + ).toBeUndefined(); + }); +}); + +test.describe("layouts: settings UI", () => { + test("uploaded layout renders with delete and preview buttons", async () => { + await uploadTestLayout(sharedPage, "E2E Test Layout", TEST_LAYOUT_ID); + + await sharedPage.goto("/settings/keyboard"); + await sharedPage.waitForLoadState("networkidle"); + + const deleteBtn = sharedPage.getByTestId(`delete-layout-${TEST_LAYOUT_ID}`); + const previewBtn = sharedPage.getByTestId(`preview-layout-${TEST_LAYOUT_ID}`); + await expect(deleteBtn, "delete button visible for custom layout").toBeVisible({ + timeout: 10000, + }); + await expect(previewBtn, "preview button visible for custom layout").toBeVisible({ + timeout: 5000, + }); + + // Cleanup via API rather than UI to keep the test focused. + await callJsonRpc(sharedPage, "deleteKeyboardLayout", { id: TEST_LAYOUT_ID }); + }); +}); diff --git a/ui/e2e/keyboard-ui.spec.ts b/ui/e2e/keyboard-ui.spec.ts new file mode 100644 index 000000000..4643bda74 --- /dev/null +++ b/ui/e2e/keyboard-ui.spec.ts @@ -0,0 +1,137 @@ +/** + * Virtual keyboard UI tests (no host required). + * + * Asserts the parts of the virtual keyboard whose behaviour is purely + * client-side and observable via DOM: + * - toggle visibility from the action bar / hide button + * - detach/attach buttons swap correctly + * - clicking a modifier keycap with latching enabled holds it (data-layer + * on .vkb reflects the shift / altgr layer) + * - clicking again releases the latch (data-layer returns to "all") + * + * Tests that need round-trip HID verification live in + * remote-agent/keyboard-paste.spec.ts and keyboard-macros.spec.ts. + * + * Run with: + * JETKVM_URL=http:// npx playwright test keyboard-ui --project=ui + */ +import { test, expect, type Page } from "@playwright/test"; +import { ensureLocalAuthMode, goToSession } from "./helpers"; + +// Standard USB HID scancodes for the modifier keys we exercise. +const HID_LSHIFT = 0xe1; +const HID_RALTGR = 0xe6; + +test.describe.configure({ mode: "serial" }); + +let sharedPage: Page; + +test.beforeAll(async ({ browser }) => { + test.setTimeout(60_000); + sharedPage = await browser.newPage(); + await ensureLocalAuthMode(sharedPage, { mode: "noPassword" }); + await goToSession(sharedPage); +}); + +test.afterAll(async () => { + await sharedPage?.close(); +}); + +async function ensureVirtualKeyboardVisible(page: Page): Promise { + const vkb = page.locator(".vkb"); + if (await vkb.isVisible().catch(() => false)) return; + // ActionBar exposes the toggle by its localized label; finding it by role + // works regardless of icon font order. + const toggle = page.getByRole("button", { name: /Virtual Keyboard/i }).first(); + await toggle.click(); + await expect(vkb).toBeVisible({ timeout: 5000 }); +} + +// modifierLatching defaults to `true` in the settings store. These tests +// rely on that default; if a future change flips the default, set it via +// the settings page or through a window-exposed setter. + +test.describe("virtual keyboard: visibility", () => { + test("toggle button shows the keyboard, hide button collapses it", async () => { + await ensureVirtualKeyboardVisible(sharedPage); + + const vkb = sharedPage.locator(".vkb"); + await expect(vkb).toBeVisible(); + + const hideButton = sharedPage.getByTestId("virtual-keyboard-hide"); + await hideButton.click(); + + // The wrapper stays in the DOM but its container slides out — the .vkb + // node leaves the viewport. The clearer signal is the toggle button + // becoming clickable again with the keyboard re-shown. + const toggle = sharedPage.getByRole("button", { name: /Virtual Keyboard/i }).first(); + await toggle.click(); + await expect(vkb).toBeVisible({ timeout: 5000 }); + }); + + test("detach/attach buttons swap based on state", async () => { + await ensureVirtualKeyboardVisible(sharedPage); + + const detach = sharedPage.getByTestId("virtual-keyboard-detach"); + const attach = sharedPage.getByTestId("virtual-keyboard-attach"); + + if (await detach.isVisible().catch(() => false)) { + await detach.click(); + await expect(attach).toBeVisible({ timeout: 3000 }); + await attach.click(); + await expect(detach).toBeVisible({ timeout: 3000 }); + } else { + // Already detached from a prior test + await attach.click(); + await expect(detach).toBeVisible({ timeout: 3000 }); + } + }); +}); + +test.describe("virtual keyboard: layout layer switching", () => { + test("clicking a latched shift key flips data-layer to shift, click again releases", async () => { + await ensureVirtualKeyboardVisible(sharedPage); + + const vkb = sharedPage.locator(".vkb"); + await expect(vkb).toHaveAttribute("data-layer", "all", { timeout: 5000 }); + + // Click LeftShift via its data-scancode. + const lshift = vkb.locator(`[data-scancode="${HID_LSHIFT}"]`).first(); + await lshift.click(); + await expect(vkb).toHaveAttribute("data-layer", "shift", { timeout: 3000 }); + + // Click again — latch toggles off, layer returns to "all". + await lshift.click(); + await expect(vkb).toHaveAttribute("data-layer", "all", { timeout: 3000 }); + }); + + test("AltGr latch produces altgr layer", async () => { + // RAlt (HID 0xE6) is the AltGr scancode; every built-in layout's + // physical right-alt key carries it, so the keycap is always present. + await ensureVirtualKeyboardVisible(sharedPage); + + const vkb = sharedPage.locator(".vkb"); + await expect(vkb).toHaveAttribute("data-layer", "all", { timeout: 5000 }); + + const altgr = vkb.locator(`[data-scancode="${HID_RALTGR}"]`).first(); + await altgr.click(); + await expect(vkb).toHaveAttribute("data-layer", "altgr", { timeout: 3000 }); + await altgr.click(); + await expect(vkb).toHaveAttribute("data-layer", "all", { timeout: 3000 }); + }); +}); + +test.describe("virtual keyboard: keycap rendering", () => { + test("keycaps carry data-scancode for every typeable key", async () => { + await ensureVirtualKeyboardVisible(sharedPage); + const vkb = sharedPage.locator(".vkb"); + + // Every standard letter A–Z (HID 0x04..0x1d) should be present somewhere + // in the rendered keyboard. We check three representative keys. + for (const scancode of [0x04, 0x16, 0x1d]) { + // A, S, Z + const key = vkb.locator(`[data-scancode="${scancode}"]`).first(); + await expect(key, `keycap with scancode ${scancode.toString(16)}`).toBeVisible(); + } + }); +}); diff --git a/ui/e2e/remote-agent/keyboard-macros.spec.ts b/ui/e2e/remote-agent/keyboard-macros.spec.ts new file mode 100644 index 000000000..52b6413e3 --- /dev/null +++ b/ui/e2e/remote-agent/keyboard-macros.spec.ts @@ -0,0 +1,265 @@ +/** + * Keyboard macro execution e2e tests. + * + * The basic "macro plays back the right keys" path is covered in + * ra-all.spec.ts. This file covers the harder edge cases: + * - per-step `delay` actually pauses execution (not just the post-step gap) + * - the MACRO_RESET step (all-zero keys + modifier) releases anything held + * - cancelExecuteMacro stops further keys from arriving and releases held keys + * + * Run with: + * JETKVM_URL=http:// JETKVM_REMOTE_HOST= \ + * npx playwright test keyboard-macros --project=keyboard-paste + */ +import { test, expect, type Page } from "@playwright/test"; +import { callJsonRpc, getDeviceHost, goToSession, restartAppViaSSH, sshExec } from "../helpers"; +import { createRemoteAgent, KEY, type KeyboardEvent as RAKeyboardEvent } from "./remote-agent"; + +const HID_KEY_BUFFER_SIZE = 6; +const HID_A = 0x04; +const HID_B = 0x05; +const HID_C = 0x06; +const HID_MOD_LSHIFT = 0x02; + +interface MacroStep { + keys: number[]; + modifier: number; + delay: number; +} + +const MACRO_RESET: MacroStep = { + keys: new Array(HID_KEY_BUFFER_SIZE).fill(0), + modifier: 0, + delay: 0, +}; + +function step(scancode: number, modifier: number, delay: number): MacroStep { + return { keys: [scancode], modifier, delay }; +} + +const agent = createRemoteAgent(); + +async function ensureNoPasswordViaAPI() { + const host = getDeviceHost(); + const status = await fetch(`http://${host}/device/status`).then( + r => r.json() as Promise<{ isSetup: boolean }>, + ); + if (!status.isSetup) { + const res = await fetch(`http://${host}/device/setup`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ localAuthMode: "noPassword" }), + }); + if (!res.ok) throw new Error(`Setup POST failed: ${res.status}`); + return; + } + const probe = await fetch(`http://${host}/device`); + if (probe.status === 401) { + await sshExec("rm -f /userdata/kvm_config.json && sync"); + await restartAppViaSSH(); + const res = await fetch(`http://${host}/device/setup`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ localAuthMode: "noPassword" }), + }); + if (!res.ok) throw new Error(`Setup POST after reset failed: ${res.status}`); + } +} + +async function runMacro(page: Page, steps: MacroStep[]): Promise { + await page.evaluate(s => window.__kvmTestHooks!.executeHidMacro(s), steps); +} + +/** Fire-and-forget: run the macro without awaiting completion. */ +async function runMacroAsync(page: Page, steps: MacroStep[]): Promise { + await page.evaluate(s => { + void window.__kvmTestHooks!.executeHidMacro(s); + }, steps); +} + +async function cancelMacro(page: Page): Promise { + await page.evaluate(() => window.__kvmTestHooks!.cancelExecuteMacro()); +} + +async function waitForKeyPressCount( + predicate: (ev: RAKeyboardEvent) => boolean, + minCount: number, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + let last: RAKeyboardEvent[] = []; + while (Date.now() < deadline) { + const events = await agent!.getKeyboardEvents(); + last = events.filter(ev => ev.type === "key_press" && predicate(ev)); + if (last.length >= minCount) return last; + await new Promise(r => setTimeout(r, 50)); + } + throw new Error( + `Timed out waiting for ${minCount} matching key_press events; got ${last.length}`, + ); +} + +test.describe.configure({ mode: "serial" }); + +let sharedPage: Page; + +test.beforeAll(async ({ browser }) => { + test.skip(!agent, "JETKVM_REMOTE_HOST not set"); + test.setTimeout(60_000); + + await Promise.all([agent!.ensureDeployed(), ensureNoPasswordViaAPI()]); + + sharedPage = await browser.newPage(); + await goToSession(sharedPage); + await agent!.waitForInputDevices(["keyboard", "absolute_mouse", "relative_mouse"], 30000); +}); + +test.afterAll(async () => { + // Force a clean keyboard state in case a test left a key held. + await cancelMacro(sharedPage).catch(() => {}); + await sharedPage?.close(); +}); + +test.beforeEach(async () => { + await agent!.clearKeyboardEvents(); +}); + +test.describe("macro: timing", () => { + test("per-step delay actually paces key events on the host", async () => { + // Three keys with a 400ms delay after each. The interval between + // host-side timestamps for key_press events should reflect the delay + // (allowing for HID/USB jitter — assert lower bound only). + const steps: MacroStep[] = [ + step(HID_A, 0, 400), + { ...MACRO_RESET, delay: 400 }, + step(HID_B, 0, 400), + { ...MACRO_RESET, delay: 400 }, + step(HID_C, 0, 400), + { ...MACRO_RESET, delay: 0 }, + ]; + const t0 = Date.now(); + await runMacro(sharedPage, steps); + const elapsed = Date.now() - t0; + + // Three keys × ~400ms each, but allow generous slack for HID round-trip. + expect(elapsed, "macro should not complete instantly").toBeGreaterThan(800); + + const events = await agent!.getKeyboardEvents(); + const presses = events.filter( + ev => + ev.type === "key_press" && (ev.code === KEY.A || ev.code === KEY.B || ev.code === KEY.C), + ); + expect(presses.length).toBe(3); + expect(presses.map(ev => ev.code)).toEqual([KEY.A, KEY.B, KEY.C]); + + // Each consecutive press should be ≥ ~250ms apart (400ms delay − margin). + for (let i = 1; i < presses.length; i++) { + const gap = presses[i].time_ms - presses[i - 1].time_ms; + expect(gap, `gap between press ${i - 1} and ${i}`).toBeGreaterThan(250); + } + }); +}); + +test.describe("macro: MACRO_RESET", () => { + test("explicit reset between keys releases the modifier", async () => { + // Press Shift+A, reset, then press B without modifier. The host should + // see LSHIFT release before B press — i.e. B is lowercase (no shift held). + const steps: MacroStep[] = [ + step(HID_A, HID_MOD_LSHIFT, 50), + { ...MACRO_RESET, delay: 50 }, + step(HID_B, 0, 50), + { ...MACRO_RESET, delay: 0 }, + ]; + await runMacro(sharedPage, steps); + + const events = await agent!.getKeyboardEvents(); + const interesting = events.filter( + ev => ev.code === KEY.LEFT_SHIFT || ev.code === KEY.A || ev.code === KEY.B, + ); + // Required ordering: shift down, A down, A up, shift up, then B down (no shift). + const shiftRelease = interesting.find( + ev => ev.code === KEY.LEFT_SHIFT && ev.type === "key_release", + ); + const bPress = interesting.find(ev => ev.code === KEY.B && ev.type === "key_press"); + expect(shiftRelease, "shift must be released after first step").toBeDefined(); + expect(bPress, "B must be pressed").toBeDefined(); + expect( + bPress!.time_ms, + "B press happens after shift release (reset cleared modifier)", + ).toBeGreaterThanOrEqual(shiftRelease!.time_ms); + }); +}); + +test.describe("macro: cancel", () => { + test("cancelExecuteMacro stops further keys from arriving", async () => { + // 8 keys with 300ms between them = ~2.4s total. Cancel after ~600ms. + // Expect at most a few keys delivered, not all 8. + const steps: MacroStep[] = []; + for (let i = 0; i < 8; i++) { + steps.push(step(HID_A, 0, 300)); + steps.push({ ...MACRO_RESET, delay: 300 }); + } + + await runMacroAsync(sharedPage, steps); + // Wait until the host sees at least 1 press to confirm the macro started. + await waitForKeyPressCount(ev => ev.code === KEY.A, 1, 3000); + await new Promise(r => setTimeout(r, 600)); + + const beforeCancel = (await agent!.getKeyboardEvents()).filter( + ev => ev.type === "key_press" && ev.code === KEY.A, + ).length; + await cancelMacro(sharedPage); + + // Give the device a moment to flush any in-flight HID report. + await new Promise(r => setTimeout(r, 1500)); + + const afterCancel = (await agent!.getKeyboardEvents()).filter( + ev => ev.type === "key_press" && ev.code === KEY.A, + ).length; + expect(beforeCancel, "macro must have started").toBeGreaterThan(0); + expect(beforeCancel, "macro must not have completed before cancel").toBeLessThan(8); + // At most one extra in-flight key may slip through after cancel. + expect(afterCancel - beforeCancel).toBeLessThanOrEqual(1); + expect(afterCancel, "cancel must prevent the full sequence").toBeLessThan(8); + }); + + test("cancel mid-hold releases the held modifier on the host", async () => { + // First step holds shift+A for a long time. Cancel before the macro's + // own reset runs. The host must see LSHIFT release — otherwise shift + // would be stuck pressed. + const steps: MacroStep[] = [ + { keys: [HID_A], modifier: HID_MOD_LSHIFT, delay: 5000 }, + { ...MACRO_RESET, delay: 0 }, + ]; + await runMacroAsync(sharedPage, steps); + await waitForKeyPressCount(ev => ev.code === KEY.LEFT_SHIFT, 1, 3000); + await new Promise(r => setTimeout(r, 200)); + await cancelMacro(sharedPage); + + const deadline = Date.now() + 3000; + let sawShiftRelease = false; + while (Date.now() < deadline && !sawShiftRelease) { + const events = await agent!.getKeyboardEvents(); + sawShiftRelease = events.some(ev => ev.code === KEY.LEFT_SHIFT && ev.type === "key_release"); + if (!sawShiftRelease) await new Promise(r => setTimeout(r, 100)); + } + expect(sawShiftRelease, "host should see shift released after cancel").toBe(true); + }); +}); + +test.describe("macro: getKeyboardLayouts smoke", () => { + // Quick sanity check that the data path the paste/macro tests rely on + // (JSON-RPC layout fetch) works for at least one built-in. + test("getKeyboardLayouts returns at least one layout", async () => { + const layouts = (await callJsonRpc(sharedPage, "getKeyboardLayouts")) as Array<{ + id: string; + name: string; + }>; + expect(Array.isArray(layouts)).toBe(true); + expect(layouts.length).toBeGreaterThan(0); + expect( + layouts.find(l => l.id === "en-US"), + "en-US must be a built-in", + ).toBeDefined(); + }); +}); diff --git a/ui/e2e/remote-agent/keyboard-paste.spec.ts b/ui/e2e/remote-agent/keyboard-paste.spec.ts new file mode 100644 index 000000000..54ee6e804 --- /dev/null +++ b/ui/e2e/remote-agent/keyboard-paste.spec.ts @@ -0,0 +1,278 @@ +/** + * Paste round-trip tests. + * + * Drives the same paste pipeline the PasteModal uses: fetches the active + * keyboard layout via JSON-RPC, builds a scancode-based KeyboardMacroStep[] + * from the charMap (mirroring PasteModal.onConfirmPaste), then executes it + * through the executeHidMacro test hook. The remote agent verifies that the + * host received the expected key-press sequence. + * + * Catches regressions in: layout charMap correctness (Go), JSON-RPC layout + * payload, paste macro construction (TS), HID-RPC transport, USB delivery. + * + * Run with: + * JETKVM_URL=http:// JETKVM_REMOTE_HOST= \ + * npx playwright test keyboard-paste --project=remote-agent + */ +import { test, expect, type Page } from "@playwright/test"; +import { callJsonRpc, getDeviceHost, goToSession, restartAppViaSSH, sshExec } from "../helpers"; +import { + createRemoteAgent, + KEY, + HID_TO_LINUX, + type KeyboardEvent as RAKeyboardEvent, +} from "./remote-agent"; + +const HID_KEY_BUFFER_SIZE = 6; + +interface HIDCombo { + s: number; // scancode + m: number; // modifier byte + p?: HIDCombo; // dead-key prefix +} + +interface KeyboardLayout { + id: string; + name: string; + charMap: Record; +} + +interface MacroStep { + keys: number[]; + modifier: number; + delay: number; +} + +const agent = createRemoteAgent(); +const layoutCache = new Map(); + +async function getLayout(page: Page, id: string): Promise { + const cached = layoutCache.get(id); + if (cached) return cached; + const layout = (await callJsonRpc(page, "getKeyboardLayoutData", { id })) as KeyboardLayout; + layoutCache.set(id, layout); + return layout; +} + +/** + * Build a paste macro from text — mirrors PasteModal.onConfirmPaste verbatim + * so a passing test here implies the production paste path works the same way. + */ +function buildPasteMacro(layout: KeyboardLayout, text: string, delay = 20): MacroStep[] { + const reset: MacroStep = { + keys: new Array(HID_KEY_BUFFER_SIZE).fill(0), + modifier: 0, + delay, + }; + const macro: MacroStep[] = []; + for (const { segment } of new Intl.Segmenter().segment(text)) { + const normalized = segment.normalize("NFC"); + const combo = layout.charMap[normalized]; + if (!combo || combo.s === 0) continue; + if (combo.p) { + macro.push({ keys: [combo.p.s], modifier: combo.p.m, delay: 20 }); + macro.push({ ...reset }); + } + macro.push({ keys: [combo.s], modifier: combo.m, delay: 20 }); + macro.push({ ...reset }); + } + return macro; +} + +async function pasteText(page: Page, layoutId: string, text: string): Promise { + const layout = await getLayout(page, layoutId); + const macro = buildPasteMacro(layout, text); + await page.evaluate(steps => window.__kvmTestHooks!.executeHidMacro(steps), macro); + return macro; +} + +/** + * Wait until the remote agent reports key_press events covering all expected + * key codes in order (other key_press events between them are ignored — paste + * with shift modifier produces interleaved shift/key press events). + */ +async function waitForKeyPresses(expected: number[], timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + let lastPresses: number[] = []; + while (Date.now() < deadline) { + const events = await agent!.getKeyboardEvents(); + const presses = events.filter(ev => ev.type === "key_press"); + lastPresses = presses.map(ev => ev.code); + let idx = 0; + for (const code of lastPresses) { + if (code === expected[idx]) { + idx++; + if (idx === expected.length) return presses; + } + } + await new Promise(r => setTimeout(r, 100)); + } + throw new Error( + `Timed out (${timeoutMs}ms) waiting for key sequence [${expected.join(", ")}]; got [${lastPresses.join(", ")}]`, + ); +} + +/** Convert a charMap-built MacroStep[] to expected Linux key codes. */ +function macroToLinuxCodes(macro: MacroStep[]): number[] { + const codes: number[] = []; + for (const step of macro) { + for (const sc of step.keys) { + if (sc === 0) continue; + const linux = HID_TO_LINUX[sc]; + if (linux !== undefined) codes.push(linux); + } + } + return codes; +} + +/** + * Ensure the device has its config wiped to noPassword mode so the WebRTC + * session can be opened without going through the welcome flow. Mirrors the + * helper in ra-all.spec.ts. + */ +async function ensureNoPasswordViaAPI() { + const host = getDeviceHost(); + const status = await fetch(`http://${host}/device/status`).then( + r => r.json() as Promise<{ isSetup: boolean }>, + ); + if (!status.isSetup) { + const res = await fetch(`http://${host}/device/setup`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ localAuthMode: "noPassword" }), + }); + if (!res.ok) throw new Error(`Setup POST failed: ${res.status}`); + return; + } + const probe = await fetch(`http://${host}/device`); + if (probe.status === 401) { + await sshExec("rm -f /userdata/kvm_config.json && sync"); + await restartAppViaSSH(); + const res = await fetch(`http://${host}/device/setup`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ localAuthMode: "noPassword" }), + }); + if (!res.ok) throw new Error(`Setup POST after reset failed: ${res.status}`); + } +} + +test.describe.configure({ mode: "serial" }); + +let sharedPage: Page; + +test.beforeAll(async ({ browser }) => { + test.skip(!agent, "JETKVM_REMOTE_HOST not set"); + test.setTimeout(60_000); + + await Promise.all([agent!.ensureDeployed(), ensureNoPasswordViaAPI()]); + + sharedPage = await browser.newPage(); + await goToSession(sharedPage); + await agent!.waitForInputDevices(["keyboard", "absolute_mouse", "relative_mouse"], 30000); +}); + +test.afterAll(async () => { + await sharedPage?.close(); +}); + +test.beforeEach(async () => { + await agent!.clearKeyboardEvents(); +}); + +test.describe("paste", () => { + test("regression: literal space character is delivered (not silently dropped)", async () => { + // Was a bug: layouts had Space's legend normalized to "Space" (5 chars), + // so addChar rejected it on the rune-count==1 check and " " never made it + // into charMap. PasteModal would then skip every space silently. + const macro = await pasteText(sharedPage, "en-US", "a b"); + expect(macro.length).toBeGreaterThan(0); + await waitForKeyPresses([KEY.A, KEY.SPACE, KEY.B]); + }); + + test("plain ASCII with multiple words and spaces", async () => { + await pasteText(sharedPage, "en-US", "hi there"); + await waitForKeyPresses([KEY.H, KEY.I, KEY.SPACE, KEY.T, KEY.H, KEY.E, KEY.R, KEY.E]); + }); + + test("uppercase letters trigger the shift modifier", async () => { + const macro = await pasteText(sharedPage, "en-US", "Hi"); + // Macro must include a step with the shift modifier byte (USB HID modifier + // bit 0x02 = LeftShift) for the H key — verifies charMap.m is non-zero. + const hStep = macro.find(s => s.keys.includes(0x0b)); // 0x0B = HID H + expect(hStep, "macro should contain a step that presses H").toBeDefined(); + expect(hStep!.modifier & 0x02).toBe(0x02); + // And the host receives both the shift and the H key. + const presses = await waitForKeyPresses([KEY.H, KEY.I]); + const codes = presses.map(ev => ev.code); + expect(codes).toContain(KEY.LEFT_SHIFT); + }); + + test("multi-line: Enter is delivered between lines", async () => { + await pasteText(sharedPage, "en-US", "a\nb"); + await waitForKeyPresses([KEY.A, KEY.ENTER, KEY.B]); + }); + + test("digits and basic punctuation", async () => { + await pasteText(sharedPage, "en-US", "a1.,;"); + await waitForKeyPresses([KEY.A, KEY.KEY_1, KEY.DOT, KEY.COMMA, KEY.SEMICOLON]); + }); + + test("invalid characters are silently skipped (valid chars still delivered)", async () => { + // ☃ snowman is not in any built-in layout's charMap. + await pasteText(sharedPage, "en-US", "a☃b"); + await waitForKeyPresses([KEY.A, KEY.B]); + // And no extra noise — only A, then B (modulo the modifier-clearing reset). + const events = await agent!.getKeyboardEvents(); + const printable = events + .filter(ev => ev.type === "key_press") + .filter(ev => ev.code === KEY.A || ev.code === KEY.B || ev.code === KEY.SPACE); + expect(printable.length).toBe(2); + }); +}); + +test.describe("paste: layout-specific charMap", () => { + test("de-DE layout: ÄÖÜ are delivered (not silently dropped like en-US would)", async () => { + // German umlauts are in de-DE charMap but absent from en-US. The macro + // construction succeeds with de-DE; pasting through en-US would yield 0 steps. + const enMacro = await pasteText(sharedPage, "en-US", "äöü"); + expect(enMacro.length, "en-US has no umlauts in charMap, macro should be empty").toBe(0); + + await agent!.clearKeyboardEvents(); + const deMacro = await pasteText(sharedPage, "de-DE", "äöü"); + expect(deMacro.length, "de-DE charMap should have entries for äöü").toBeGreaterThan(0); + + // Each umlaut must produce at least one HID key event reaching the host. + // Don't assert specific Linux key codes — the host's input layer maps the + // scancodes to whatever its own keyboard layout dictates, and our test + // host is en-US. We just verify the keystrokes arrive. + const linuxCodes = macroToLinuxCodes(deMacro).filter(c => c !== KEY.LEFT_SHIFT); + await waitForKeyPresses(linuxCodes); + }); + + test("de-DE QWERTZ vs en-US QWERTY: 'y' goes to different scancodes", async () => { + // Verifies layout switching actually changes the macro. The same letter + // 'y' is on different physical keys in the two layouts. + const enLayout = await getLayout(sharedPage, "en-US"); + const deLayout = await getLayout(sharedPage, "de-DE"); + const enY = enLayout.charMap["y"]; + const deY = deLayout.charMap["y"]; + expect(enY, "en-US charMap['y']").toBeDefined(); + expect(deY, "de-DE charMap['y']").toBeDefined(); + expect(enY!.s, "en-US 'y' and de-DE 'y' use different HID scancodes").not.toBe(deY!.s); + }); + + test("dead-key composition: fr-FR composes â as ^ then a", async () => { + const layout = await getLayout(sharedPage, "fr-FR"); + const combo = layout.charMap["â"]; + expect(combo, "fr-FR charMap should contain â").toBeDefined(); + expect(combo!.p, "â must be a dead-key composition (^ then a)").toBeDefined(); + + const macro = await pasteText(sharedPage, "fr-FR", "â"); + // Macro: prefix-step (^), reset, base-step (a), reset. + // Both prefix and base produce key_press events on the host. + const linuxCodes = macroToLinuxCodes(macro).filter(c => c !== KEY.LEFT_SHIFT); + expect(linuxCodes.length, "â paste should produce two distinct key events").toBe(2); + await waitForKeyPresses(linuxCodes); + }); +}); diff --git a/ui/e2e/remote-agent/ra-all.spec.ts b/ui/e2e/remote-agent/ra-all.spec.ts index 7e6960895..6bba959ab 100644 --- a/ui/e2e/remote-agent/ra-all.spec.ts +++ b/ui/e2e/remote-agent/ra-all.spec.ts @@ -25,6 +25,10 @@ import { waitForLedState, restartAppViaSSH, semverGte, + goToSession, + goToKeyboardSettings, + getLayouts, + getLayoutData, } from "../helpers"; import { createRemoteAgent, @@ -3009,3 +3013,429 @@ test.describe("Remote Host Agent", () => { } }); }); + +// --------------------------------------------------------------------------- +// JSON-RPC: Layout API +// --------------------------------------------------------------------------- + +test.describe("Keyboard Layout JSON-RPC API", () => { + test.beforeEach(async ({ page }) => { + await goToSession(page); + }); + + test("getKeyboardLayouts returns built-in layouts", async ({ page }) => { + const layouts = await getLayouts(page); + expect(layouts.length).toBeGreaterThanOrEqual(19); + + // en-US must always exist + const enUS = layouts.find(l => l.id === "en-US"); + expect(enUS).toBeDefined(); + expect(enUS!.builtin).toBe(true); + + // nl-BE alias should be present + const nlBE = layouts.find(l => l.id === "nl-BE"); + expect(nlBE).toBeDefined(); + expect(nlBE!.builtin).toBe(true); + + // All built-ins should be flagged + const builtins = layouts.filter(l => l.builtin); + expect(builtins.length).toBeGreaterThanOrEqual(19); + }); + + test("getKeyboardLayoutData returns full en-US layout", async ({ page }) => { + const layout = await getLayoutData(page, "en-US"); + expect(layout.id).toBe("en-US"); + expect(layout.keys.length).toBeGreaterThanOrEqual(100); + expect(layout.boardW).toBeGreaterThan(20); + + // charMap should have common characters + expect(layout.charMap).toHaveProperty("a"); + expect(layout.charMap).toHaveProperty("A"); + expect(layout.charMap).toHaveProperty("1"); + expect(layout.charMap).toHaveProperty("!"); + }); + + test("getKeyboardLayoutData returns de-DE with dead key compositions", async ({ page }) => { + const layout = await getLayoutData(page, "de-DE"); + expect(layout.id).toBe("de-DE"); + + // Dead key composition: ^+a → â should be in charMap with prefix + const aCirc = layout.charMap["â"] as { s: number; m: number; p?: unknown }; + expect(aCirc).toBeDefined(); + expect(aCirc.p).toBeDefined(); // has dead key prefix + }); + + test("getKeyboardLayoutData falls back to en-US for unknown ID", async ({ page }) => { + const layout = await getLayoutData(page, "xx-NONEXISTENT"); + expect(layout.id).toBe("en-US"); + }); + + test("nl-BE alias returns same keys as fr-BE", async ({ page }) => { + const nlBE = await getLayoutData(page, "nl-BE"); + const frBE = await getLayoutData(page, "fr-BE"); + expect(nlBE.keys.length).toBe(frBE.keys.length); + expect(Object.keys(nlBE.charMap).length).toBe(Object.keys(frBE.charMap).length); + }); +}); + +// --------------------------------------------------------------------------- +// Settings page: Layout selection +// --------------------------------------------------------------------------- + +test.describe("Keyboard Settings Page", () => { + test.beforeEach(async ({ page }) => { + await goToSession(page); + }); + + test("settings page shows layout dropdown with built-ins", async ({ page }) => { + await goToKeyboardSettings(page); + + // The layout listbox should be visible + const listbox = page.getByRole("listbox").first(); + await expect(listbox).toBeVisible({ timeout: 10000 }); + }); + + test("preview button opens layout preview dialog", async ({ page }) => { + await goToKeyboardSettings(page); + + // Click preview for en-US + const previewBtn = page.locator('[data-testid="preview-layout-en-US"]').first(); + await previewBtn.click(); + + // Preview dialog should open with a virtual keyboard + const vkb = page.locator(".vkb"); + await expect(vkb).toBeVisible({ timeout: 5000 }); + + // Should show keys + const keys = page.locator(".vkb .key"); + const count = await keys.count(); + expect(count).toBeGreaterThanOrEqual(50); + + // Close dialog + const closeBtn = page.locator('[data-testid="preview-layout-close"]'); + await closeBtn.click(); + await expect(vkb).not.toBeVisible(); + }); +}); + +// --------------------------------------------------------------------------- +// Virtual Keyboard: Rendering +// --------------------------------------------------------------------------- + +test.describe("Virtual Keyboard Rendering", () => { + test.beforeEach(async ({ page }) => { + await goToSession(page); + }); + + test("virtual keyboard toggle shows and hides keyboard", async ({ page }) => { + // The virtual keyboard toggle button should exist + const toggleBtn = page + .locator("button") + .filter({ hasText: /keyboard/i }) + .first(); + + // If keyboard is not visible, click to show + const vkb = page.locator(".vkb"); + if (!(await vkb.isVisible())) { + await toggleBtn.click(); + } + await expect(vkb).toBeVisible({ timeout: 5000 }); + + // Should have keys rendered + const keys = page.locator(".vkb .key"); + const count = await keys.count(); + expect(count).toBeGreaterThanOrEqual(50); + + // Hide button should work + const hideBtn = page.locator('[data-testid="virtual-keyboard-hide"]'); + await hideBtn.click(); + await expect(vkb).not.toBeVisible(); + }); + + test("virtual keyboard shows all legend quadrants in default (all) mode", async ({ page }) => { + // Show keyboard + const toggleBtn = page + .locator("button") + .filter({ hasText: /keyboard/i }) + .first(); + const vkb = page.locator(".vkb"); + if (!(await vkb.isVisible())) { + await toggleBtn.click(); + } + await expect(vkb).toBeVisible({ timeout: 5000 }); + + // Default layer should be "all" + const layer = await vkb.getAttribute("data-layer"); + expect(layer).toBe("all"); + + // In "all" mode, both normal and shift legends should be visible on a letter key + // Find a key with data-scancode for 'a' (scancode 4) + const aKey = page.locator('.vkb .key[data-scancode="4"]'); + const aCount = await aKey.count(); + expect(aCount).toBeGreaterThanOrEqual(1); + if (aCount > 0) { + const normalLegend = aKey.locator(".legend.normal"); + const shiftLegend = aKey.locator(".legend.shift"); + await expect(normalLegend).toBeVisible(); + await expect(shiftLegend).toBeVisible(); + } + }); + + test("virtual keyboard has correct data-scancode attributes", async ({ page }) => { + // Show keyboard + const toggleBtn = page + .locator("button") + .filter({ hasText: /keyboard/i }) + .first(); + const vkb = page.locator(".vkb"); + if (!(await vkb.isVisible())) { + await toggleBtn.click(); + } + await expect(vkb).toBeVisible({ timeout: 5000 }); + + // Check some known scancodes exist + const escKey = page.locator('.vkb .key[data-scancode="41"]'); // Escape = 0x29 = 41 + expect(await escKey.count()).toBeGreaterThanOrEqual(1); + + const spaceKey = page.locator('.vkb .key[data-scancode="44"]'); // Space = 0x2C = 44 + expect(await spaceKey.count()).toBeGreaterThanOrEqual(1); + + const enterKey = page.locator('.vkb .key[data-scancode="40"]'); // Enter = 0x28 = 40 + expect(await enterKey.count()).toBeGreaterThanOrEqual(1); + }); +}); + +// --------------------------------------------------------------------------- +// Virtual Keyboard: LED indicators +// --------------------------------------------------------------------------- + +test.describe("Virtual Keyboard LED Indicators", () => { + test.beforeEach(async ({ page }) => { + await goToSession(page); + }); + + test("CapsLock LED indicator appears when CapsLock is toggled", async ({ page }) => { + // Show keyboard + const toggleBtn = page + .locator("button") + .filter({ hasText: /keyboard/i }) + .first(); + const vkb = page.locator(".vkb"); + if (!(await vkb.isVisible())) { + await toggleBtn.click(); + } + await expect(vkb).toBeVisible({ timeout: 5000 }); + + // Get initial CapsLock state + const initialLed = await getLedState(page); + const initialCaps = initialLed?.caps_lock ?? false; + + // Toggle CapsLock + await tapKey(page, HID_KEY.CAPS_LOCK); + await waitForLedState(page, "caps_lock", !initialCaps); + + // The .vkb should now have (or not have) the caps-lock-on class + if (!initialCaps) { + await expect(vkb).toHaveClass(/caps-lock-on/); + } else { + await expect(vkb).not.toHaveClass(/caps-lock-on/); + } + + // Restore original state + await tapKey(page, HID_KEY.CAPS_LOCK); + await waitForLedState(page, "caps_lock", initialCaps); + }); +}); + +// --------------------------------------------------------------------------- +// Virtual Keyboard: Key click sends HID +// --------------------------------------------------------------------------- + +test.describe("Virtual Keyboard Key Interaction", () => { + test.beforeEach(async ({ page }) => { + await goToSession(page); + }); + + test("clicking a virtual key sends the correct scancode", async ({ page }) => { + // Show keyboard + const toggleBtn = page + .locator("button") + .filter({ hasText: /keyboard/i }) + .first(); + const vkb = page.locator(".vkb"); + if (!(await vkb.isVisible())) { + await toggleBtn.click(); + } + await expect(vkb).toBeVisible({ timeout: 5000 }); + + // Get initial CapsLock state + const initialLed = await getLedState(page); + const initialCaps = initialLed?.caps_lock ?? false; + + // Click the CapsLock key on the virtual keyboard (scancode 57 = 0x39) + const capsKey = page.locator('.vkb .key[data-scancode="57"]'); + const capsCount = await capsKey.count(); + expect(capsCount).toBeGreaterThanOrEqual(1); + if (capsCount > 0) { + await capsKey.click(); + + // CapsLock state should toggle + await waitForLedState(page, "caps_lock", !initialCaps); + + // Restore + await capsKey.click(); + await waitForLedState(page, "caps_lock", initialCaps); + } + }); + + test("detach and attach buttons work", async ({ page }) => { + // Show keyboard + const toggleBtn = page + .locator("button") + .filter({ hasText: /keyboard/i }) + .first(); + const vkb = page.locator(".vkb"); + if (!(await vkb.isVisible())) { + await toggleBtn.click(); + } + await expect(vkb).toBeVisible({ timeout: 5000 }); + + // Detach + const detachBtn = page.locator('[data-testid="virtual-keyboard-detach"]'); + if ((await detachBtn.count()) > 0) { + await detachBtn.click(); + // After detach, attach button should appear + const attachBtn = page.locator('[data-testid="virtual-keyboard-attach"]'); + await expect(attachBtn).toBeVisible({ timeout: 3000 }); + + // Re-attach + await attachBtn.click(); + await expect(detachBtn).toBeVisible({ timeout: 3000 }); + } + }); +}); + +// --------------------------------------------------------------------------- +// Upload and Delete custom layout +// --------------------------------------------------------------------------- + +test.describe("Custom Layout Upload and Delete", () => { + const testLayoutKLE = JSON.stringify([ + { name: "E2E Test Layout", author: "Playwright" }, + ["Esc", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12"], + [{ w: 1.5 }, "Tab", "Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P", "[\n{", "]\n}"], + [ + { w: 1.75 }, + "Caps", + "A", + "S", + "D", + "F", + "G", + "H", + "J", + "K", + "L", + ";\n:", + "'\n\"", + { w: 2.25 }, + "Enter", + ], + [ + { w: 2.25 }, + "Shift", + "Z", + "X", + "C", + "V", + "B", + "N", + "M", + ",\n<", + ".\n>", + "/\n?", + { w: 2.75 }, + "Shift", + ], + [ + { w: 1.25 }, + "Ctrl", + { w: 1.25 }, + "Win", + { w: 1.25 }, + "Alt", + { w: 6.25 }, + "Space", + { w: 1.25 }, + "Alt", + { w: 1.25 }, + "Win", + { w: 1.25 }, + "Menu", + { w: 1.25 }, + "Ctrl", + ], + ]); + + let uploadedId: string | null = null; + + test("upload a custom layout via HTTP", async ({ page }) => { + await goToSession(page); + + // Upload via fetch (same as useKleUpload hook) + const result = await page.evaluate(async (kleJson: string) => { + const resp = await fetch("/keyboard/upload?name=E2E+Test+Layout", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: kleJson, + }); + return resp.json(); + }, testLayoutKLE); + + expect(result.id).toBeDefined(); + expect(result.name).toBe("E2E Test Layout"); + expect(result.keyCount).toBeGreaterThan(40); + uploadedId = result.id; + + // The uploaded layout should appear in the layout list + const layouts = await getLayouts(page); + const uploaded = layouts.find(l => l.id === uploadedId); + expect(uploaded).toBeDefined(); + expect(uploaded!.builtin).toBe(false); + }); + + test("delete the uploaded custom layout", async ({ page }) => { + await goToSession(page); + + // If we don't have an ID from a prior upload, upload one now + if (!uploadedId) { + const result = await page.evaluate(async (kleJson: string) => { + const resp = await fetch("/keyboard/upload?name=E2E+Temp+Layout", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: kleJson, + }); + return resp.json(); + }, testLayoutKLE); + uploadedId = result.id; + } + + // Delete via JSON-RPC + await callJsonRpc(page, "deleteKeyboardLayout", { id: uploadedId }); + + // Should no longer appear in list + const layouts = await getLayouts(page); + const deleted = layouts.find(l => l.id === uploadedId); + expect(deleted).toBeUndefined(); + + uploadedId = null; + }); + + test("cannot delete a built-in layout", async ({ page }) => { + await goToSession(page); + + await expect(callJsonRpc(page, "deleteKeyboardLayout", { id: "en-US" })).rejects.toThrow( + /cannot delete built-in/i, + ); + }); +}); diff --git a/ui/localization/jetKVM.UI.inlang/.gitignore b/ui/localization/jetKVM.UI.inlang/.gitignore deleted file mode 100644 index 5e4659675..000000000 --- a/ui/localization/jetKVM.UI.inlang/.gitignore +++ /dev/null @@ -1 +0,0 @@ -cache \ No newline at end of file diff --git a/ui/localization/messages/cy.json b/ui/localization/messages/cy.json index 60608f84d..8c01a16aa 100644 --- a/ui/localization/messages/cy.json +++ b/ui/localization/messages/cy.json @@ -74,6 +74,7 @@ "advanced_error_download_diagnostics": "Methwyd llwytho diagnosteg i lawr: {error}", "advanced_error_loopback_disable": "Methwyd analluogi modd loopback yn unig: {error}", "advanced_error_loopback_enable": "Methwyd galluogi modd loopback yn unig: {error}", + "advanced_error_reset_config": "Methwyd ailosod y ffurfweddiad: {error}", "advanced_error_set_dev_channel": "Methwyd gosod cyflwr sianel ddatblygu: {error}", "advanced_error_set_dev_mode": "Methwyd gosod modd datblygwr: {error}", "advanced_error_set_log_level": "Methwyd gosod lefel log: {error}", @@ -102,6 +103,9 @@ "advanced_loopback_warning_description": "RHYBUDD: Bydd hyn yn cyfyngu mynediad rhyngwyneb gwe i localhost (127.0.0.1) yn unig.", "advanced_loopback_warning_ssh": "Mynediad SSH wedi'i ffurfweddu a'i brofi", "advanced_loopback_warning_title": "Galluogi Modd Loopback yn Unig?", + "advanced_reset_config_button": "Ailosod Ffurfweddiad", + "advanced_reset_config_description": "Ailosod y ffurfweddiad i'r rhagosodiad. Bydd hyn yn eich allgofnodi.", + "advanced_reset_config_title": "Ailosod y Ffurfweddiad", "advanced_ssh_access_description": "Ychwanegwch eich allwedd gyhoeddus SSH i alluogi mynediad diogel o bell i'r ddyfais", "advanced_ssh_access_title": "Mynediad SSH", "advanced_ssh_default_user": "Y defnyddiwr SSH rhagosodedig yw", @@ -111,6 +115,7 @@ "advanced_success_download_diagnostics": "Diagnosteg wedi'i llwytho i lawr yn llwyddiannus", "advanced_success_loopback_disabled": "Modd loopback yn unig wedi'i analluogi. Ailgychwynnwch eich dyfais i'w gymhwyso.", "advanced_success_loopback_enabled": "Modd loopback yn unig wedi'i alluogi. Ailgychwynnwch eich dyfais i'w gymhwyso.", + "advanced_success_reset_config": "Ailosodiad y ffurfweddiad i'r rhagosodiad yn llwyddiannus", "advanced_success_update_ssh_key": "Allwedd SSH wedi'i diweddaru'n llwyddiannus", "advanced_title": "Uwch", "advanced_troubleshooting_log_level_description": "Addaswch fanylder log ar gyfer diagnosteg. Bydd yn ailosod i Rhybudd ar ôl ailgychwyn", @@ -411,15 +416,77 @@ "jiggler_save_jiggler_config": "Cadw Ffurfweddiad Siglwr", "jiggler_timezone_description": "Cylchfa amser ar gyfer amserlen cron", "jiggler_timezone_label": "Cylchfa Amser", + "keyboard_combo_explain_close_window": "Cau'r ffenestr", + "keyboard_combo_explain_kill_x11": "Lladd X11", + "keyboard_combo_explain_lock": "Cloi'r gweithfan", + "keyboard_combo_explain_run_dialog": "Rhedeg deialog", + "keyboard_combo_explain_spotlight": "Chwyddwydr", + "keyboard_combo_explain_task_manager": "Rheolwr Tasgau", "keyboard_description": "Ffurfweddu gosodiadau bysellfwrdd ar gyfer eich dyfais", + "keyboard_layout_custom_description": "Cynlluniau bysellfwrdd a uwchlwythwyd gan ddefnyddwyr", + "keyboard_layout_custom_preview": "Rhagolwg", + "keyboard_layout_custom_replace": "Amnewid", + "keyboard_layout_custom_title": "Cynlluniau Personol", + "keyboard_layout_custom_upload": "Uwchlwytho Cynllun", + "keyboard_layout_custom_upload_error": "Methodd yr uwchlwytho: {error}", + "keyboard_layout_custom_upload_success": "Cynllun \" {name} \" wedi'i uwchlwytho ( {keys} allweddi)", + "keyboard_layout_delete_confirm_description": "Ydych chi'n siŵr eich bod chi eisiau dileu'r cynllun \" {name} \"? Ni ellir dadwneud hyn.", + "keyboard_layout_delete_confirm_title": "Dileu Cynllun y Bysellfwrdd", + "keyboard_layout_delete_error": "Methwyd dileu'r cynllun: {error}", + "keyboard_layout_delete_success": "Cynllun \" {name} \" wedi'i ddileu", "keyboard_layout_description": "Cynllun bysellfwrdd y system weithredu darged", "keyboard_layout_error": "Methwyd gosod cynllun bysellfwrdd: {error}", "keyboard_layout_long_description": "Mae'r bysellfwrdd rhithwir, gludo testun, a macros bysellfwrdd yn anfon trawiadau bysell unigol i'r ddyfais darged. Mae'r cynllun bysellfwrdd yn pennu pa godau bysell sy'n cael eu hanfon. Sicrhewch fod cynllun y bysellfwrdd yn JetKVM yn cyfateb i'r gosodiadau yn y system weithredu.", + "keyboard_layout_none_custom": "Dim cynlluniau personol wedi'u huwchlwytho", + "keyboard_layout_preview_chars": "{count} cymeriadau wedi'u mapio", + "keyboard_layout_preview_close": "Cau", + "keyboard_layout_preview_keys": "allweddi {count}", + "keyboard_layout_preview_use": "Defnyddiwch y cynllun hwn", + "keyboard_layout_preview_using": "Gweithredol", "keyboard_layout_success": "Cynllun bysellfwrdd wedi'i osod yn llwyddiannus i {layout}", "keyboard_layout_title": "Cynllun Bysellfwrdd", + "keyboard_modifier_latching_description": "Pan gaiff ei alluogi, mae clicio ar allwedd addasu (Shift, Ctrl, Alt, ac ati) ar y bysellfwrdd rhithwir yn ei dal i lawr nes ei chlicio eto. Pan gaiff ei analluogi, caiff addaswyr eu rhyddhau ar unwaith fel allweddi rheolaidd.", + "keyboard_modifier_latching_title": "Allweddi Addasu Gludiog", + "keyboard_quick_actions_description": "Camau Cyflym", "keyboard_show_pressed_keys_description": "Dangos y bysellau sy'n cael eu pwyso ar hyn o bryd yn y bar statws", "keyboard_show_pressed_keys_title": "Dangos Bysellau wedi'u Pwyso", "keyboard_title": "Bysellfwrdd", + "keys_alt": "Alt", + "keys_altgr": "AltGr", + "keys_application": "Cais", + "keys_arrow_down": "Saeth i Lawr", + "keys_arrow_left": "Saeth i'r Chwith", + "keys_arrow_right": "Saeth i'r Dde", + "keys_arrow_up": "Saeth i Fyny", + "keys_backspace": "Backspace", + "keys_caps_lock": "Cloi Priflythyren", + "keys_command": "Gorchymyn", + "keys_control": "Rheoli", + "keys_delete": "Dileu", + "keys_end": "Diwedd", + "keys_enter": "Rhowch", + "keys_escape": "Dianc", + "keys_home": "Cartref", + "keys_insert": "Mewnosod", + "keys_menu": "Dewislen", + "keys_meta": "Meta", + "keys_modifier_altgr": "AltGr", + "keys_modifier_altgr_shift": "Shift+AltGr", + "keys_modifier_control": "Rheoli", + "keys_modifier_control_shift": "Shift+Rheoli", + "keys_modifier_kana": "Kana", + "keys_modifier_kana_shift": "Shift+Kana", + "keys_modifier_shift": "Shifft", + "keys_num_lock": "Num Lock", + "keys_option": "Opsiwn", + "keys_page_down": "Tudalen i Lawr", + "keys_page_up": "Tudalen i Fyny", + "keys_pause": "Oedi", + "keys_print_screen": "Argraffu Sgrin", + "keys_scroll_lock": "Clo Sgrolio", + "keys_shift": "Shifft", + "keys_space": "Gofod", + "keys_tab": "Tab", "kvm_terminal": "Terfynell KVM", "last_online": "Ar-lein ddiwethaf {time}", "learn_more": "Dysgu mwy", @@ -779,11 +846,19 @@ "network_settings_load_error": "Methwyd â llwytho gosodiadau rhwydwaith: {error}", "network_static_ipv4_header": "Ffurfweddiad IPv4 Statig", "network_static_ipv6_header": "Ffurfweddiad IPv6 Statig", + "network_time_sync_add_http_url": "Ychwanegu URL HTTP", + "network_time_sync_add_ntp_server": "Ychwanegu Gweinydd NTP", + "network_time_sync_config_header": "Cydamseru Amser Personol", + "network_time_sync_custom": "Personol", "network_time_sync_description": "Ffurfweddwch osodiadau cydamseru amser", "network_time_sync_http_only": "HTTP yn unig", + "network_time_sync_http_url_invalid": "URL annilys. Rhaid iddo ddechrau gyda http:// neu https://", "network_time_sync_ntp_and_http": "NTP a HTTP", "network_time_sync_ntp_only": "NTP yn unig", + "network_time_sync_ntp_server_invalid": "Gweinydd NTP annilys. Rhowch enw gwesteiwr neu gyfeiriad IP", "network_time_sync_title": "Cydamseru amser", + "network_time_sync_user_http_urls_label": "URLau HTTP", + "network_time_sync_user_ntp_servers_label": "Gweinyddion NTP", "network_title": "Rhwydwaith", "never_seen_online": "Byth wedi'i weld ar-lein", "next": "Nesaf", diff --git a/ui/localization/messages/da.json b/ui/localization/messages/da.json index 53734301c..76d3895cc 100644 --- a/ui/localization/messages/da.json +++ b/ui/localization/messages/da.json @@ -416,15 +416,77 @@ "jiggler_save_jiggler_config": "Gem Jiggler-konfiguration", "jiggler_timezone_description": "Tidszone for cron-plan", "jiggler_timezone_label": "Tidszone", + "keyboard_combo_explain_close_window": "Luk vindue", + "keyboard_combo_explain_kill_x11": "Dræb X11", + "keyboard_combo_explain_lock": "Lås arbejdsstationen", + "keyboard_combo_explain_run_dialog": "Kør dialogboks", + "keyboard_combo_explain_spotlight": "Fokus", + "keyboard_combo_explain_task_manager": "Jobliste", "keyboard_description": "Konfigurer tastaturindstillinger for din enhed", + "keyboard_layout_custom_description": "Brugeruploadede tastaturlayouts", + "keyboard_layout_custom_preview": "Forhåndsvisning", + "keyboard_layout_custom_replace": "Erstatte", + "keyboard_layout_custom_title": "Brugerdefinerede layouts", + "keyboard_layout_custom_upload": "Uploadlayout", + "keyboard_layout_custom_upload_error": "Upload mislykkedes: {error}", + "keyboard_layout_custom_upload_success": "Layout \" {name} \" uploadet ( {keys} nøgler)", + "keyboard_layout_delete_confirm_description": "Er du sikker på, at du vil slette layoutet \" {name} \"? Dette kan ikke fortrydes.", + "keyboard_layout_delete_confirm_title": "Slet tastaturlayout", + "keyboard_layout_delete_error": "Kunne ikke slette layout: {error}", + "keyboard_layout_delete_success": "Layout \" {name} \" slettet", "keyboard_layout_description": "Tastaturlayout for måloperativsystemet", "keyboard_layout_error": "Kunne ikke indstille tastaturlayout: {error}", "keyboard_layout_long_description": "Det virtuelle tastatur, indsættelse af tekst og tastaturmakroer sender individuelle tastetryk til målenheden. Tastaturlayoutet bestemmer, hvilke tastekoder der sendes. Sørg for, at tastaturlayoutet i JetKVM matcher indstillingerne i operativsystemet.", + "keyboard_layout_none_custom": "Ingen brugerdefinerede layouts er uploadet", + "keyboard_layout_preview_chars": "{count} tegn tilknyttet", + "keyboard_layout_preview_close": "Tæt", + "keyboard_layout_preview_keys": "{count} nøgler", + "keyboard_layout_preview_use": "Brug dette layout", + "keyboard_layout_preview_using": "Aktiv", "keyboard_layout_success": "Tastaturlayoutet er nu indstillet til {layout}", "keyboard_layout_title": "Tastaturlayout", + "keyboard_modifier_latching_description": "Når den er aktiveret, holder du en ændringstast (Shift, Ctrl, Alt osv.) på det virtuelle tastatur nede, indtil du klikker på den igen. Når den er deaktiveret, slippes ændringstasten med det samme som almindelige taster.", + "keyboard_modifier_latching_title": "Klæbrige ændringstaster", + "keyboard_quick_actions_description": "Hurtige handlinger", "keyboard_show_pressed_keys_description": "Vis de aktuelt nedtrykkede taster i statuslinjen", "keyboard_show_pressed_keys_title": "Vis trykkede taster", "keyboard_title": "Tastatur", + "keys_alt": "Alt", + "keys_altgr": "AltGr", + "keys_application": "Anvendelse", + "keys_arrow_down": "Pil ned", + "keys_arrow_left": "Pil til venstre", + "keys_arrow_right": "Pil til højre", + "keys_arrow_up": "Pil op", + "keys_backspace": "Tilbage", + "keys_caps_lock": "Caps Lock", + "keys_command": "Kommando", + "keys_control": "Kontrollere", + "keys_delete": "Slet", + "keys_end": "Ende", + "keys_enter": "Indtast", + "keys_escape": "Flugt", + "keys_home": "Hjem", + "keys_insert": "Indsæt", + "keys_menu": "Menu", + "keys_meta": "Meta", + "keys_modifier_altgr": "AltGr", + "keys_modifier_altgr_shift": "Skift+AltGr", + "keys_modifier_control": "Kontrollere", + "keys_modifier_control_shift": "Shift+Ctrl", + "keys_modifier_kana": "Kana", + "keys_modifier_kana_shift": "Shift+Kana", + "keys_modifier_shift": "Flytte", + "keys_num_lock": "Num Lock", + "keys_option": "Valgmulighed", + "keys_page_down": "Side ned", + "keys_page_up": "Side op", + "keys_pause": "Pause", + "keys_print_screen": "Udskriv skærm", + "keys_scroll_lock": "Scroll Lock", + "keys_shift": "Flytte", + "keys_space": "Plads", + "keys_tab": "Faneblad", "kvm_terminal": "KVM-terminal", "last_online": "Sidst online {time}", "learn_more": "Lær mere", diff --git a/ui/localization/messages/de.json b/ui/localization/messages/de.json index 8c69023af..128a96a02 100644 --- a/ui/localization/messages/de.json +++ b/ui/localization/messages/de.json @@ -416,15 +416,77 @@ "jiggler_save_jiggler_config": "Jiggler-Konfiguration speichern", "jiggler_timezone_description": "Zeitzone für Cron-Zeitplan", "jiggler_timezone_label": "Zeitzone", + "keyboard_combo_explain_close_window": "Fenster schließen", + "keyboard_combo_explain_kill_x11": "Töte X11", + "keyboard_combo_explain_lock": "Schloss-Arbeitsplatz", + "keyboard_combo_explain_run_dialog": "Ausführen-Dialog", + "keyboard_combo_explain_spotlight": "Scheinwerfer", + "keyboard_combo_explain_task_manager": "Aufgabenmanager", "keyboard_description": "Konfigurieren Sie die Tastatureinstellungen für Ihr Gerät", + "keyboard_layout_custom_description": "Vom Benutzer hochgeladene Tastaturlayouts", + "keyboard_layout_custom_preview": "Vorschau", + "keyboard_layout_custom_replace": "Ersetzen", + "keyboard_layout_custom_title": "Benutzerdefinierte Layouts", + "keyboard_layout_custom_upload": "Layout hochladen", + "keyboard_layout_custom_upload_error": "Upload fehlgeschlagen: {error}", + "keyboard_layout_custom_upload_success": "Layout \" {name} \" uploaded ( {keys} keys)", + "keyboard_layout_delete_confirm_description": "Sind Sie sicher, dass Sie das Layout \" {name} \" löschen möchten? Dies kann nicht rückgängig gemacht werden.", + "keyboard_layout_delete_confirm_title": "Tastaturlayout löschen", + "keyboard_layout_delete_error": "Layout konnte nicht gelöscht werden: {error}", + "keyboard_layout_delete_success": "Layout \" {name} \" gelöscht", "keyboard_layout_description": "Tastaturlayout des Zielbetriebssystems", "keyboard_layout_error": "Tastaturlayout konnte nicht festgelegt werden: {error}", "keyboard_layout_long_description": "Die virtuelle Tastatur, das Einfügen von Text und Tastaturmakros senden einzelne Tastenanschläge an das Zielgerät. Das Tastaturlayout bestimmt, welche Tastencodes gesendet werden. Stellen Sie sicher, dass das Tastaturlayout in JetKVM mit den Einstellungen im Betriebssystem übereinstimmt.", + "keyboard_layout_none_custom": "Es wurden keine benutzerdefinierten Layouts hochgeladen.", + "keyboard_layout_preview_chars": "{count} Zeichen zugeordnet", + "keyboard_layout_preview_close": "Schließen", + "keyboard_layout_preview_keys": "{count} keys", + "keyboard_layout_preview_use": "Verwenden Sie dieses Layout", + "keyboard_layout_preview_using": "Aktiv", "keyboard_layout_success": "Tastaturlayout erfolgreich auf {layout} eingestellt", "keyboard_layout_title": "Tastaturlayout", + "keyboard_modifier_latching_description": "Wenn diese Funktion aktiviert ist, wird eine Sondertaste (Umschalt, Strg, Alt usw.) auf der virtuellen Tastatur gedrückt gehalten, bis sie erneut angeklickt wird. Wenn sie deaktiviert ist, werden Sondertasten wie normale Tasten sofort losgelassen.", + "keyboard_modifier_latching_title": "Haftende Sondertasten", + "keyboard_quick_actions_description": "Schnellaktionen", "keyboard_show_pressed_keys_description": "Anzeige der aktuell gedrückten Tasten in der Statusleiste", "keyboard_show_pressed_keys_title": "Gedrückte Tasten anzeigen", "keyboard_title": "Tastatur", + "keys_alt": "Alternativ", + "keys_altgr": "AltGr", + "keys_application": "Anwendung", + "keys_arrow_down": "Pfeil nach unten", + "keys_arrow_left": "Pfeil nach links", + "keys_arrow_right": "Pfeil nach rechts", + "keys_arrow_up": "Pfeil nach oben", + "keys_backspace": "Rücktaste", + "keys_caps_lock": "Feststelltaste", + "keys_command": "Befehl", + "keys_control": "Kontrolle", + "keys_delete": "Löschen", + "keys_end": "Ende", + "keys_enter": "Eingeben", + "keys_escape": "Flucht", + "keys_home": "Heim", + "keys_insert": "Einfügen", + "keys_menu": "Speisekarte", + "keys_meta": "Meta", + "keys_modifier_altgr": "AltGr", + "keys_modifier_altgr_shift": "Umschalt+AltGr", + "keys_modifier_control": "Kontrolle", + "keys_modifier_control_shift": "Umschalt+Strg", + "keys_modifier_kana": "Kana", + "keys_modifier_kana_shift": "Shift+Kana", + "keys_modifier_shift": "Schicht", + "keys_num_lock": "Num-Taste", + "keys_option": "Option", + "keys_page_down": "Seite nach unten", + "keys_page_up": "Seite nach oben", + "keys_pause": "Pause", + "keys_print_screen": "Bildschirmfoto", + "keys_scroll_lock": "Rollen-Taste", + "keys_shift": "Schicht", + "keys_space": "Raum", + "keys_tab": "Tab", "kvm_terminal": "KVM-Terminal", "last_online": "Zuletzt online {time}", "learn_more": "Mehr erfahren", diff --git a/ui/localization/messages/en.json b/ui/localization/messages/en.json index 219bbd3cb..9ba094f77 100644 --- a/ui/localization/messages/en.json +++ b/ui/localization/messages/en.json @@ -416,15 +416,77 @@ "jiggler_save_jiggler_config": "Save Jiggler Config", "jiggler_timezone_description": "Timezone for cron schedule", "jiggler_timezone_label": "Timezone", + "keyboard_combo_explain_close_window": "Close window", + "keyboard_combo_explain_kill_x11": "Kill X11", + "keyboard_combo_explain_lock": "Lock workstation", + "keyboard_combo_explain_run_dialog": "Run dialog", + "keyboard_combo_explain_spotlight": "Spotlight", + "keyboard_combo_explain_task_manager": "Task Manager", "keyboard_description": "Configure keyboard settings for your device", + "keyboard_layout_custom_description": "User-uploaded keyboard layouts", + "keyboard_layout_custom_preview": "Preview", + "keyboard_layout_custom_replace": "Replace", + "keyboard_layout_custom_title": "Custom Layouts", + "keyboard_layout_custom_upload": "Upload Layout", + "keyboard_layout_custom_upload_error": "Upload failed: {error}", + "keyboard_layout_custom_upload_success": "Layout \"{name}\" uploaded ({keys} keys)", + "keyboard_layout_delete_confirm_description": "Are you sure you want to delete the layout \"{name}\"? This cannot be undone.", + "keyboard_layout_delete_confirm_title": "Delete Keyboard Layout", + "keyboard_layout_delete_error": "Failed to delete layout: {error}", + "keyboard_layout_delete_success": "Layout \"{name}\" deleted", "keyboard_layout_description": "Keyboard layout of target operating system", "keyboard_layout_error": "Failed to set keyboard layout: {error}", "keyboard_layout_long_description": "The virtual keyboard, paste text, and keyboard macros send individual key strokes to the target device. The keyboard layout determines which key codes are being sent. Ensure that the keyboard layout in JetKVM matches the settings in the operating system.", + "keyboard_layout_none_custom": "No custom layouts uploaded", + "keyboard_layout_preview_chars": "{count} characters mapped", + "keyboard_layout_preview_close": "Close", + "keyboard_layout_preview_keys": "{count} keys", + "keyboard_layout_preview_use": "Use this layout", + "keyboard_layout_preview_using": "Active", "keyboard_layout_success": "Keyboard layout set successfully to {layout}", "keyboard_layout_title": "Keyboard Layout", + "keyboard_modifier_latching_description": "When enabled, clicking a modifier key (Shift, Ctrl, Alt, etc.) on the virtual keyboard holds it down until clicked again. When disabled, modifiers are released immediately like regular keys.", + "keyboard_modifier_latching_title": "Sticky Modifier Keys", + "keyboard_quick_actions_description": "Quick Actions", "keyboard_show_pressed_keys_description": "Display currently pressed keys in the status bar", "keyboard_show_pressed_keys_title": "Show Pressed Keys", "keyboard_title": "Keyboard", + "keys_alt": "Alt", + "keys_altgr": "AltGr", + "keys_application": "Application", + "keys_arrow_down": "Arrow Down", + "keys_arrow_left": "Arrow Left", + "keys_arrow_right": "Arrow Right", + "keys_arrow_up": "Arrow Up", + "keys_backspace": "Backspace", + "keys_caps_lock": "Caps Lock", + "keys_command": "Command", + "keys_control": "Control", + "keys_delete": "Delete", + "keys_end": "End", + "keys_enter": "Enter", + "keys_escape": "Escape", + "keys_home": "Home", + "keys_insert": "Insert", + "keys_menu": "Menu", + "keys_meta": "Meta", + "keys_modifier_altgr": "AltGr", + "keys_modifier_altgr_shift": "Shift+AltGr", + "keys_modifier_control": "Control", + "keys_modifier_control_shift": "Shift+Control", + "keys_modifier_kana": "Kana", + "keys_modifier_kana_shift": "Shift+Kana", + "keys_modifier_shift": "Shift", + "keys_num_lock": "Num Lock", + "keys_option": "Option", + "keys_page_down": "Page Down", + "keys_page_up": "Page Up", + "keys_pause": "Pause", + "keys_print_screen": "Print Screen", + "keys_scroll_lock": "Scroll Lock", + "keys_shift": "Shift", + "keys_space": "Space", + "keys_tab": "Tab", "kvm_terminal": "KVM Terminal", "last_online": "Last online {time}", "learn_more": "Learn more", diff --git a/ui/localization/messages/es.json b/ui/localization/messages/es.json index 01bbbff18..4959b8531 100644 --- a/ui/localization/messages/es.json +++ b/ui/localization/messages/es.json @@ -416,15 +416,77 @@ "jiggler_save_jiggler_config": "Guardar configuración de Jiggler", "jiggler_timezone_description": "Zona horaria para la programación cron", "jiggler_timezone_label": "Zona horaria", + "keyboard_combo_explain_close_window": "Cerrar ventana", + "keyboard_combo_explain_kill_x11": "Matar X11", + "keyboard_combo_explain_lock": "Estación de trabajo con cerradura", + "keyboard_combo_explain_run_dialog": "Ejecutar diálogo", + "keyboard_combo_explain_spotlight": "Destacar", + "keyboard_combo_explain_task_manager": "Administrador de tareas", "keyboard_description": "Configure los ajustes del teclado para su dispositivo", + "keyboard_layout_custom_description": "Diseños de teclado subidos por los usuarios", + "keyboard_layout_custom_preview": "Avance", + "keyboard_layout_custom_replace": "Reemplazar", + "keyboard_layout_custom_title": "Diseños personalizados", + "keyboard_layout_custom_upload": "Cargar diseño", + "keyboard_layout_custom_upload_error": "Error al cargar: {error}", + "keyboard_layout_custom_upload_success": "Diseño \" {name} \" cargado ( {keys} keys)", + "keyboard_layout_delete_confirm_description": "¿Está seguro de que desea eliminar el diseño \" {name} \"? Esto no se puede deshacer.", + "keyboard_layout_delete_confirm_title": "Eliminar la distribución del teclado", + "keyboard_layout_delete_error": "Error al eliminar el diseño: {error}", + "keyboard_layout_delete_success": "Diseño \" {name} \" eliminado", "keyboard_layout_description": "Disposición del teclado del sistema operativo de destino", "keyboard_layout_error": "No se pudo establecer la distribución del teclado: {error}", "keyboard_layout_long_description": "El teclado virtual, la función de pegar texto y las macros de teclado envían pulsaciones de teclas individuales al dispositivo de destino. La distribución del teclado determina qué códigos de tecla se envían. Asegúrese de que la distribución del teclado en JetKVM coincida con la configuración del sistema operativo.", + "keyboard_layout_none_custom": "No se han subido diseños personalizados.", + "keyboard_layout_preview_chars": "{count} caracteres mapeados", + "keyboard_layout_preview_close": "Cerca", + "keyboard_layout_preview_keys": "{count} claves", + "keyboard_layout_preview_use": "Utilice este diseño", + "keyboard_layout_preview_using": "Activo", "keyboard_layout_success": "La distribución del teclado se ha establecido correctamente en {layout}", "keyboard_layout_title": "Distribución del teclado", + "keyboard_modifier_latching_description": "Cuando está activada, al pulsar una tecla modificadora (Mayús, Ctrl, Alt, etc.) en el teclado virtual, esta permanece pulsada hasta que se vuelve a pulsar. Cuando está desactivada, las teclas modificadoras se liberan inmediatamente, como las teclas normales.", + "keyboard_modifier_latching_title": "Teclas modificadoras fijas", + "keyboard_quick_actions_description": "Acciones rápidas", "keyboard_show_pressed_keys_description": "Mostrar las teclas presionadas actualmente en la barra de estado", "keyboard_show_pressed_keys_title": "Mostrar teclas presionadas", "keyboard_title": "Teclado", + "keys_alt": "Alt", + "keys_altgr": "AltGr", + "keys_application": "Solicitud", + "keys_arrow_down": "Flecha hacia abajo", + "keys_arrow_left": "Flecha izquierda", + "keys_arrow_right": "Flecha derecha", + "keys_arrow_up": "Flecha hacia arriba", + "keys_backspace": "Retroceso", + "keys_caps_lock": "Bloq Mayús", + "keys_command": "Dominio", + "keys_control": "Control", + "keys_delete": "Borrar", + "keys_end": "Fin", + "keys_enter": "Ingresar", + "keys_escape": "Escapar", + "keys_home": "Hogar", + "keys_insert": "Insertar", + "keys_menu": "Menú", + "keys_meta": "Meta", + "keys_modifier_altgr": "AltGr", + "keys_modifier_altgr_shift": "Mayús+AltGr", + "keys_modifier_control": "Control", + "keys_modifier_control_shift": "Mayús+Control", + "keys_modifier_kana": "Kana", + "keys_modifier_kana_shift": "Shift+Kana", + "keys_modifier_shift": "Cambio", + "keys_num_lock": "Bloq Num", + "keys_option": "Opción", + "keys_page_down": "Página abajo", + "keys_page_up": "Página arriba", + "keys_pause": "Pausa", + "keys_print_screen": "Captura de pantalla", + "keys_scroll_lock": "Bloq Despl", + "keys_shift": "Cambio", + "keys_space": "Espacio", + "keys_tab": "Pestaña", "kvm_terminal": "Terminal KVM", "last_online": "Última conexión {time}", "learn_more": "Más información", diff --git a/ui/localization/messages/fr.json b/ui/localization/messages/fr.json index 58f9bdbcf..8eca20d74 100644 --- a/ui/localization/messages/fr.json +++ b/ui/localization/messages/fr.json @@ -416,15 +416,77 @@ "jiggler_save_jiggler_config": "Enregistrer la configuration de Jiggler", "jiggler_timezone_description": "Fuseau horaire pour la planification cron", "jiggler_timezone_label": "Fuseau horaire", + "keyboard_combo_explain_close_window": "Fermer la fenêtre", + "keyboard_combo_explain_kill_x11": "Tuer X11", + "keyboard_combo_explain_lock": "Poste de travail de verrouillage", + "keyboard_combo_explain_run_dialog": "Exécuter la boîte de dialogue", + "keyboard_combo_explain_spotlight": "Mettre en lumière", + "keyboard_combo_explain_task_manager": "Gestionnaire de tâches", "keyboard_description": "Configurer les paramètres du clavier pour votre appareil", + "keyboard_layout_custom_description": "Agencements de clavier téléchargés par les utilisateurs", + "keyboard_layout_custom_preview": "Aperçu", + "keyboard_layout_custom_replace": "Remplacer", + "keyboard_layout_custom_title": "Mises en page personnalisées", + "keyboard_layout_custom_upload": "Mise en page du téléchargement", + "keyboard_layout_custom_upload_error": "Échec du chargement : {error}", + "keyboard_layout_custom_upload_success": "Mise en page \" {name} \" téléchargé ( {keys} clés)", + "keyboard_layout_delete_confirm_description": "Êtes-vous sûr de vouloir supprimer la mise en page « {name} \"? Cette opération est irréversible.", + "keyboard_layout_delete_confirm_title": "Supprimer la disposition du clavier", + "keyboard_layout_delete_error": "Échec de la suppression de la mise en page : {error}", + "keyboard_layout_delete_success": "Disposition \" {name} \" supprimée", "keyboard_layout_description": "Disposition du clavier du système d'exploitation cible", "keyboard_layout_error": "Échec de la définition de la disposition du clavier : {error}", "keyboard_layout_long_description": "Le clavier virtuel, le collage de texte et les macros clavier envoient des frappes de touches individuelles au périphérique cible. La disposition du clavier détermine les codes de touches envoyés. Assurez-vous que la disposition du clavier dans JetKVM correspond aux paramètres du système d'exploitation.", + "keyboard_layout_none_custom": "Aucune mise en page personnalisée téléchargée", + "keyboard_layout_preview_chars": "{count} caractères mappés", + "keyboard_layout_preview_close": "Fermer", + "keyboard_layout_preview_keys": "{count} clés", + "keyboard_layout_preview_use": "Utilisez cette mise en page", + "keyboard_layout_preview_using": "Actif", "keyboard_layout_success": "La disposition du clavier a été définie avec succès sur {layout}", "keyboard_layout_title": "Disposition du clavier", + "keyboard_modifier_latching_description": "Lorsque cette fonction est activée, cliquer sur une touche de modification (Maj, Ctrl, Alt, etc.) du clavier virtuel la maintient enfoncée jusqu'à ce qu'on clique à nouveau dessus. Lorsqu'elle est désactivée, les touches de modification sont relâchées immédiatement, comme les touches classiques.", + "keyboard_modifier_latching_title": "Touches de modification rémanentes", + "keyboard_quick_actions_description": "Actions rapides", "keyboard_show_pressed_keys_description": "Afficher les touches actuellement enfoncées dans la barre d'état", "keyboard_show_pressed_keys_title": "Afficher les touches enfoncées", "keyboard_title": "Clavier", + "keys_alt": "Alt", + "keys_altgr": "AltGr", + "keys_application": "Application", + "keys_arrow_down": "Flèche vers le bas", + "keys_arrow_left": "Flèche gauche", + "keys_arrow_right": "Flèche droite", + "keys_arrow_up": "Flèche vers le haut", + "keys_backspace": "Retour arrière", + "keys_caps_lock": "Verrouillage des majuscules", + "keys_command": "Commande", + "keys_control": "Contrôle", + "keys_delete": "Supprimer", + "keys_end": "Fin", + "keys_enter": "Entrer", + "keys_escape": "S'échapper", + "keys_home": "Maison", + "keys_insert": "Insérer", + "keys_menu": "Menu", + "keys_meta": "Méta", + "keys_modifier_altgr": "AltGr", + "keys_modifier_altgr_shift": "Maj+AltGr", + "keys_modifier_control": "Contrôle", + "keys_modifier_control_shift": "Maj+Contrôle", + "keys_modifier_kana": "Kana", + "keys_modifier_kana_shift": "Maj+Kana", + "keys_modifier_shift": "Changement", + "keys_num_lock": "Verr Num", + "keys_option": "Option", + "keys_page_down": "Page suivante", + "keys_page_up": "Page précédente", + "keys_pause": "Pause", + "keys_print_screen": "Capture d'écran", + "keys_scroll_lock": "Verr. défilement", + "keys_shift": "Changement", + "keys_space": "Espace", + "keys_tab": "Languette", "kvm_terminal": "Terminal KVM", "last_online": "Dernière connexion {time}", "learn_more": "En savoir plus", diff --git a/ui/localization/messages/it.json b/ui/localization/messages/it.json index 9cd3363e4..c188ec643 100644 --- a/ui/localization/messages/it.json +++ b/ui/localization/messages/it.json @@ -416,15 +416,77 @@ "jiggler_save_jiggler_config": "Salva la configurazione di Jiggler", "jiggler_timezone_description": "Fuso orario per la pianificazione cron", "jiggler_timezone_label": "Fuso orario", + "keyboard_combo_explain_close_window": "Chiudi la finestra", + "keyboard_combo_explain_kill_x11": "Uccidi X11", + "keyboard_combo_explain_lock": "Postazione di lavoro con serratura", + "keyboard_combo_explain_run_dialog": "Supporto per l'esecuzione", + "keyboard_combo_explain_spotlight": "Riflettore", + "keyboard_combo_explain_task_manager": "Gestore delle attività", "keyboard_description": "Configura le impostazioni della tastiera per il tuo dispositivo", + "keyboard_layout_custom_description": "Layout di tastiera caricati dagli utenti", + "keyboard_layout_custom_preview": "Anteprima", + "keyboard_layout_custom_replace": "Sostituire", + "keyboard_layout_custom_title": "Layout personalizzati", + "keyboard_layout_custom_upload": "Caricamento layout", + "keyboard_layout_custom_upload_error": "Caricamento non riuscito: {error}", + "keyboard_layout_custom_upload_success": "Layout \" {name} \" caricato ( {keys} chiavi)", + "keyboard_layout_delete_confirm_description": "Sei sicuro di voler eliminare il layout \" {name} \"? Questa operazione non può essere annullata.", + "keyboard_layout_delete_confirm_title": "Elimina layout tastiera", + "keyboard_layout_delete_error": "Impossibile eliminare il layout: {error}", + "keyboard_layout_delete_success": "Layout \" {name} \" eliminato", "keyboard_layout_description": "Layout della tastiera del sistema operativo di destinazione", "keyboard_layout_error": "Impossibile impostare il layout della tastiera: {error}", "keyboard_layout_long_description": "La tastiera virtuale, la funzione \"Incolla testo\" e le macro della tastiera inviano singole sequenze di tasti al dispositivo di destinazione. Il layout della tastiera determina quali codici tasto vengono inviati. Assicurarsi che il layout della tastiera in JetKVM corrisponda alle impostazioni del sistema operativo.", + "keyboard_layout_none_custom": "Nessun layout personalizzato caricato", + "keyboard_layout_preview_chars": "{count} caratteri mappati", + "keyboard_layout_preview_close": "Vicino", + "keyboard_layout_preview_keys": "{count} chiavi", + "keyboard_layout_preview_use": "Utilizza questo layout", + "keyboard_layout_preview_using": "Attivo", "keyboard_layout_success": "Layout della tastiera impostato correttamente su {layout}", "keyboard_layout_title": "Layout della tastiera", + "keyboard_modifier_latching_description": "Quando questa funzione è attiva, cliccando su un tasto modificatore (Shift, Ctrl, Alt, ecc.) sulla tastiera virtuale, il tasto rimane premuto finché non viene cliccato di nuovo. Quando è disattivata, i tasti modificatori vengono rilasciati immediatamente come i tasti normali.", + "keyboard_modifier_latching_title": "Tasti modificatori permanenti", + "keyboard_quick_actions_description": "Azioni rapide", "keyboard_show_pressed_keys_description": "Visualizza i tasti attualmente premuti nella barra di stato", "keyboard_show_pressed_keys_title": "Mostra i tasti premuti", "keyboard_title": "Tastiera", + "keys_alt": "Alt", + "keys_altgr": "AltGr", + "keys_application": "Applicazione", + "keys_arrow_down": "Freccia in basso", + "keys_arrow_left": "Freccia a sinistra", + "keys_arrow_right": "Freccia a destra", + "keys_arrow_up": "Freccia in su", + "keys_backspace": "Backspace", + "keys_caps_lock": "Blocco maiuscole", + "keys_command": "Comando", + "keys_control": "Controllare", + "keys_delete": "Eliminare", + "keys_end": "FINE", + "keys_enter": "Inserisci", + "keys_escape": "Fuga", + "keys_home": "Casa", + "keys_insert": "Inserire", + "keys_menu": "Menu", + "keys_meta": "Meta", + "keys_modifier_altgr": "AltGr", + "keys_modifier_altgr_shift": "Maiusc+AltGr", + "keys_modifier_control": "Controllare", + "keys_modifier_control_shift": "Maiusc+Controllo", + "keys_modifier_kana": "Kana", + "keys_modifier_kana_shift": "Maiusc+Kana", + "keys_modifier_shift": "Spostare", + "keys_num_lock": "Blocco numeri", + "keys_option": "Opzione", + "keys_page_down": "Pagina giù", + "keys_page_up": "Pagina su", + "keys_pause": "Pausa", + "keys_print_screen": "Stampa schermata", + "keys_scroll_lock": "Blocco scorrimento", + "keys_shift": "Spostare", + "keys_space": "Spazio", + "keys_tab": "Tab", "kvm_terminal": "Terminale KVM", "last_online": "Ultimo accesso {time}", "learn_more": "Saperne di più", diff --git a/ui/localization/messages/ja.json b/ui/localization/messages/ja.json index 3bd4f3b2b..ca1f907c2 100644 --- a/ui/localization/messages/ja.json +++ b/ui/localization/messages/ja.json @@ -416,15 +416,77 @@ "jiggler_save_jiggler_config": "ジグラー設定を保存", "jiggler_timezone_description": "Cronスケジュールのタイムゾーン", "jiggler_timezone_label": "タイムゾーン", + "keyboard_combo_explain_close_window": "ウィンドウを閉じる", + "keyboard_combo_explain_kill_x11": "X11を殺せ", + "keyboard_combo_explain_lock": "ワークステーションをロックする", + "keyboard_combo_explain_run_dialog": "ダイアログの実行", + "keyboard_combo_explain_spotlight": "スポットライト", + "keyboard_combo_explain_task_manager": "タスクマネージャー", "keyboard_description": "デバイスのキーボード設定を構成します", + "keyboard_layout_custom_description": "ユーザーがアップロードしたキーボードレイアウト", + "keyboard_layout_custom_preview": "プレビュー", + "keyboard_layout_custom_replace": "交換する", + "keyboard_layout_custom_title": "カスタムレイアウト", + "keyboard_layout_custom_upload": "レイアウトをアップロード", + "keyboard_layout_custom_upload_error": "アップロードに失敗しました: {error}", + "keyboard_layout_custom_upload_success": "レイアウト \" {name} \" がアップロードされました ( {keys}キー)", + "keyboard_layout_delete_confirm_description": "レイアウト「 {name} 」を削除してもよろしいですか?この操作は元に戻せません。", + "keyboard_layout_delete_confirm_title": "キーボードレイアウトを削除", + "keyboard_layout_delete_error": "レイアウトの削除に失敗しました: {error}", + "keyboard_layout_delete_success": "レイアウト \" {name} \" が削除されました", "keyboard_layout_description": "ターゲットOSのキーボードレイアウト", "keyboard_layout_error": "キーボードレイアウトの設定に失敗しました: {error}", "keyboard_layout_long_description": "仮想キーボード、テキスト貼り付け、キーボードマクロは、個々のキーストロークをターゲットデバイスに送信します。JetKVMのキーボードレイアウトが、OSの設定と一致していることを確認してください。", + "keyboard_layout_none_custom": "カスタムレイアウトはアップロードされていません", + "keyboard_layout_preview_chars": "{count}文字がマッピングされました", + "keyboard_layout_preview_close": "近い", + "keyboard_layout_preview_keys": "{count}キー", + "keyboard_layout_preview_use": "このレイアウトを使用してください", + "keyboard_layout_preview_using": "アクティブ", "keyboard_layout_success": "キーボードレイアウトが {layout} に設定されました", "keyboard_layout_title": "キーボードレイアウト", + "keyboard_modifier_latching_description": "有効にすると、仮想キーボード上の修飾キー(Shift、Ctrl、Altなど)をクリックした際に、もう一度クリックするまでキーが押されたままになります。無効にすると、修飾キーは通常のキーと同様にすぐに離されます。", + "keyboard_modifier_latching_title": "固定修飾キー", + "keyboard_quick_actions_description": "クイックアクション", "keyboard_show_pressed_keys_description": "現在押されているキーをステータスバーに表示する", "keyboard_show_pressed_keys_title": "押下キーを表示", "keyboard_title": "キーボード", + "keys_alt": "代替", + "keys_altgr": "AltGr", + "keys_application": "応用", + "keys_arrow_down": "下向き矢印", + "keys_arrow_left": "左矢印", + "keys_arrow_right": "右矢印", + "keys_arrow_up": "上向き矢印", + "keys_backspace": "バックスペース", + "keys_caps_lock": "キャップスロック", + "keys_command": "指示", + "keys_control": "コントロール", + "keys_delete": "消去", + "keys_end": "終わり", + "keys_enter": "入力", + "keys_escape": "逃げる", + "keys_home": "家", + "keys_insert": "入れる", + "keys_menu": "メニュー", + "keys_meta": "メタ", + "keys_modifier_altgr": "AltGr", + "keys_modifier_altgr_shift": "Shift+AltGr", + "keys_modifier_control": "コントロール", + "keys_modifier_control_shift": "Shift+Control", + "keys_modifier_kana": "仮名", + "keys_modifier_kana_shift": "Shift+かな", + "keys_modifier_shift": "シフト", + "keys_num_lock": "Num Lock", + "keys_option": "オプション", + "keys_page_down": "ページダウン", + "keys_page_up": "ページアップ", + "keys_pause": "一時停止", + "keys_print_screen": "プリントスクリーン", + "keys_scroll_lock": "スクロールロック", + "keys_shift": "シフト", + "keys_space": "空間", + "keys_tab": "タブ", "kvm_terminal": "KVMターミナル", "last_online": "最終オンライン: {time}", "learn_more": "詳細を見る", diff --git a/ui/localization/messages/nb.json b/ui/localization/messages/nb.json index f08ced348..dadb04e18 100644 --- a/ui/localization/messages/nb.json +++ b/ui/localization/messages/nb.json @@ -416,15 +416,77 @@ "jiggler_save_jiggler_config": "Lagre Jiggler-konfigurasjon", "jiggler_timezone_description": "Tidssone for cron-plan", "jiggler_timezone_label": "Tidssone", + "keyboard_combo_explain_close_window": "Lukk vinduet", + "keyboard_combo_explain_kill_x11": "Drep X11", + "keyboard_combo_explain_lock": "Lås arbeidsstasjonen", + "keyboard_combo_explain_run_dialog": "Kjør dialogboksen", + "keyboard_combo_explain_spotlight": "Søkelyset", + "keyboard_combo_explain_task_manager": "Oppgavebehandling", "keyboard_description": "Konfigurer tastaturinnstillinger for enheten din", + "keyboard_layout_custom_description": "Brukeropplastede tastaturoppsett", + "keyboard_layout_custom_preview": "Forhåndsvisning", + "keyboard_layout_custom_replace": "Erstatt", + "keyboard_layout_custom_title": "Tilpassede oppsett", + "keyboard_layout_custom_upload": "Last opp oppsett", + "keyboard_layout_custom_upload_error": "Opplasting mislyktes: {error}", + "keyboard_layout_custom_upload_success": "Layout \" {name} \" lastet opp ( {keys} nøkler)", + "keyboard_layout_delete_confirm_description": "Er du sikker på at du vil slette layouten « {name} «? Dette kan ikke angres.", + "keyboard_layout_delete_confirm_title": "Slett tastaturoppsett", + "keyboard_layout_delete_error": "Klarte ikke å slette layout: {error}", + "keyboard_layout_delete_success": "Layout \" {name} \" slettet", "keyboard_layout_description": "Tastaturoppsett for måloperativsystemet", "keyboard_layout_error": "Klarte ikke å angi tastaturoppsett: {error}", "keyboard_layout_long_description": "Det virtuelle tastaturet, limetekst og tastaturmakroer sender individuelle tastetrykk til målenheten. Tastaturoppsettet bestemmer hvilke tastekoder som sendes. Sørg for at tastaturoppsettet i JetKVM samsvarer med innstillingene i operativsystemet.", + "keyboard_layout_none_custom": "Ingen tilpassede oppsett lastet opp", + "keyboard_layout_preview_chars": "{count} tegn tilordnet", + "keyboard_layout_preview_close": "Lukke", + "keyboard_layout_preview_keys": "{count} nøkler", + "keyboard_layout_preview_use": "Bruk denne layouten", + "keyboard_layout_preview_using": "Aktiv", "keyboard_layout_success": "Tastaturoppsettet er satt til {layout}", "keyboard_layout_title": "Tastaturoppsett", + "keyboard_modifier_latching_description": "Når den er aktivert, holder du en spesialtast (Shift, Ctrl, Alt osv.) på det virtuelle tastaturet nede til du klikker på den igjen. Når den er deaktivert, slippes spesialtastene umiddelbart, som vanlige taster.", + "keyboard_modifier_latching_title": "Klebrige modifikatortaster", + "keyboard_quick_actions_description": "Hurtighandlinger", "keyboard_show_pressed_keys_description": "Vis taster som for øyeblikket trykkes ned i statuslinjen", "keyboard_show_pressed_keys_title": "Vis trykkede taster", "keyboard_title": "Tastatur", + "keys_alt": "Alt", + "keys_altgr": "AltGr", + "keys_application": "Søknad", + "keys_arrow_down": "Pil ned", + "keys_arrow_left": "Pil venstre", + "keys_arrow_right": "Pil høyre", + "keys_arrow_up": "Pil opp", + "keys_backspace": "Tilbake", + "keys_caps_lock": "Caps Lock", + "keys_command": "Kommando", + "keys_control": "Kontroll", + "keys_delete": "Slett", + "keys_end": "Slutt", + "keys_enter": "Gå", + "keys_escape": "Flykte", + "keys_home": "Hjem", + "keys_insert": "Sett inn", + "keys_menu": "Meny", + "keys_meta": "Meta", + "keys_modifier_altgr": "AltGr", + "keys_modifier_altgr_shift": "Shift+AltGr", + "keys_modifier_control": "Kontroll", + "keys_modifier_control_shift": "Skift+Kontroll", + "keys_modifier_kana": "Kana", + "keys_modifier_kana_shift": "Shift+Kana", + "keys_modifier_shift": "Skifte", + "keys_num_lock": "Num Lock", + "keys_option": "Alternativ", + "keys_page_down": "Side ned", + "keys_page_up": "Side opp", + "keys_pause": "Pause", + "keys_print_screen": "Utskriftsskjerm", + "keys_scroll_lock": "Scroll Lock", + "keys_shift": "Skifte", + "keys_space": "Rom", + "keys_tab": "Fane", "kvm_terminal": "KVM-terminal", "last_online": "Sist online {time}", "learn_more": "Lær mer", diff --git a/ui/localization/messages/pt.json b/ui/localization/messages/pt.json index 33da87a09..9580be5ec 100644 --- a/ui/localization/messages/pt.json +++ b/ui/localization/messages/pt.json @@ -416,15 +416,77 @@ "jiggler_save_jiggler_config": "Salvar Configuração do Jiggler", "jiggler_timezone_description": "Fuso horário para agendamento cron", "jiggler_timezone_label": "Fuso Horário", + "keyboard_combo_explain_close_window": "Fechar janela", + "keyboard_combo_explain_kill_x11": "Mate X11", + "keyboard_combo_explain_lock": "Estação de trabalho com trava", + "keyboard_combo_explain_run_dialog": "Executar diálogo", + "keyboard_combo_explain_spotlight": "Destaque", + "keyboard_combo_explain_task_manager": "Gerenciador de Tarefas", "keyboard_description": "Configure as opções de teclado para o seu dispositivo", + "keyboard_layout_custom_description": "Layouts de teclado enviados pelos usuários", + "keyboard_layout_custom_preview": "Pré-visualização", + "keyboard_layout_custom_replace": "Substituir", + "keyboard_layout_custom_title": "Layouts personalizados", + "keyboard_layout_custom_upload": "Layout de upload", + "keyboard_layout_custom_upload_error": "Falha no upload: {error}", + "keyboard_layout_custom_upload_success": "Layout \" {name} \" enviado ( {keys} chaves)", + "keyboard_layout_delete_confirm_description": "Tem certeza de que deseja excluir o layout \" {name} \"? Esta ação não pode ser desfeita.", + "keyboard_layout_delete_confirm_title": "Excluir layout de teclado", + "keyboard_layout_delete_error": "Falha ao excluir o layout: {error}", + "keyboard_layout_delete_success": "Layout \" {name} \" excluído", "keyboard_layout_description": "Layout do teclado do sistema operacional de destino", "keyboard_layout_error": "Falha ao definir layout do teclado: {error}", "keyboard_layout_long_description": "O teclado virtual, colar texto e macros de teclado enviam toques de tecla individuais para o dispositivo de destino. O layout do teclado determina quais códigos de tecla estão sendo enviados. Certifique-se de que o layout do teclado no JetKVM corresponda às configurações no sistema operacional.", + "keyboard_layout_none_custom": "Nenhum layout personalizado foi carregado.", + "keyboard_layout_preview_chars": "{count} caracteres mapeados", + "keyboard_layout_preview_close": "Fechar", + "keyboard_layout_preview_keys": "{count} chaves", + "keyboard_layout_preview_use": "Use este layout", + "keyboard_layout_preview_using": "Ativo", "keyboard_layout_success": "Layout do teclado definido com sucesso para {layout}", "keyboard_layout_title": "Layout do Teclado", + "keyboard_modifier_latching_description": "Quando ativada, clicar em uma tecla modificadora (Shift, Ctrl, Alt, etc.) no teclado virtual a mantém pressionada até que seja clicada novamente. Quando desativada, as teclas modificadoras são liberadas imediatamente, como as teclas normais.", + "keyboard_modifier_latching_title": "Teclas modificadoras persistentes", + "keyboard_quick_actions_description": "Ações rápidas", "keyboard_show_pressed_keys_description": "Exibir teclas atualmente pressionadas na barra de status", "keyboard_show_pressed_keys_title": "Mostrar Teclas Pressionadas", "keyboard_title": "Teclado", + "keys_alt": "Alternativa", + "keys_altgr": "AltGr", + "keys_application": "Aplicativo", + "keys_arrow_down": "Seta para baixo", + "keys_arrow_left": "Seta para a esquerda", + "keys_arrow_right": "Seta para a direita", + "keys_arrow_up": "Seta para cima", + "keys_backspace": "Apagar", + "keys_caps_lock": "Caps Lock", + "keys_command": "Comando", + "keys_control": "Controlar", + "keys_delete": "Excluir", + "keys_end": "Fim", + "keys_enter": "Digitar", + "keys_escape": "Escapar", + "keys_home": "Lar", + "keys_insert": "Inserir", + "keys_menu": "Menu", + "keys_meta": "Meta", + "keys_modifier_altgr": "AltGr", + "keys_modifier_altgr_shift": "Shift+AltGr", + "keys_modifier_control": "Controlar", + "keys_modifier_control_shift": "Shift+Control", + "keys_modifier_kana": "Kana", + "keys_modifier_kana_shift": "Shift+Kana", + "keys_modifier_shift": "Mudança", + "keys_num_lock": "Num Lock", + "keys_option": "Opção", + "keys_page_down": "Página abaixo", + "keys_page_up": "Página acima", + "keys_pause": "Pausa", + "keys_print_screen": "Imprimir tela", + "keys_scroll_lock": "Bloqueio de rolagem", + "keys_shift": "Mudança", + "keys_space": "Espaço", + "keys_tab": "Aba", "kvm_terminal": "Terminal KVM", "last_online": "Última vez online {time}", "learn_more": "Saiba mais", diff --git a/ui/localization/messages/ru.json b/ui/localization/messages/ru.json index 4582ce6c5..568721206 100644 --- a/ui/localization/messages/ru.json +++ b/ui/localization/messages/ru.json @@ -416,15 +416,77 @@ "jiggler_save_jiggler_config": "Сохранить конфигурацию Jiggler", "jiggler_timezone_description": "Часовой пояс для расписания cron", "jiggler_timezone_label": "Часовой пояс", + "keyboard_combo_explain_close_window": "Закрыть окно", + "keyboard_combo_explain_kill_x11": "Убить X11", + "keyboard_combo_explain_lock": "Заблокируйте рабочее место", + "keyboard_combo_explain_run_dialog": "Диалоговое окно \"Выполнить\"", + "keyboard_combo_explain_spotlight": "В центре внимания", + "keyboard_combo_explain_task_manager": "Диспетчер задач", "keyboard_description": "Настройте параметры клавиатуры для вашего устройства", + "keyboard_layout_custom_description": "Раскладки клавиатуры, загруженные пользователями.", + "keyboard_layout_custom_preview": "Предварительный просмотр", + "keyboard_layout_custom_replace": "Заменять", + "keyboard_layout_custom_title": "Пользовательские макеты", + "keyboard_layout_custom_upload": "Макет загрузки", + "keyboard_layout_custom_upload_error": "Загрузка не удалась: {error}", + "keyboard_layout_custom_upload_success": "Макет \" {name} \" загружено ( {keys} ключи)", + "keyboard_layout_delete_confirm_description": "Вы уверены, что хотите удалить макет \" {name} \"? Это действие необратимо.", + "keyboard_layout_delete_confirm_title": "Удалить раскладку клавиатуры", + "keyboard_layout_delete_error": "Не удалось удалить макет: {error}", + "keyboard_layout_delete_success": "Макет \" {name} \" удален", "keyboard_layout_description": "Раскладка клавиатуры целевой операционной системы", "keyboard_layout_error": "Не удалось установить раскладку клавиатуры: {error}", "keyboard_layout_long_description": "Виртуальная клавиатура, вставка текста и макросы клавиатуры отправляют отдельные нажатия клавиш на целевое устройство. Раскладка клавиатуры определяет, какие коды клавиш отправляются. Убедитесь, что раскладка клавиатуры в JetKVM соответствует настройкам в операционной системе.", + "keyboard_layout_none_custom": "Пользовательские макеты не загружены", + "keyboard_layout_preview_chars": "{count} символов отображено", + "keyboard_layout_preview_close": "Закрывать", + "keyboard_layout_preview_keys": "{count} keys", + "keyboard_layout_preview_use": "Используйте этот макет", + "keyboard_layout_preview_using": "Активный", "keyboard_layout_success": "Раскладка клавиатуры успешно установлена на {layout}", "keyboard_layout_title": "Раскладка клавиатуры", + "keyboard_modifier_latching_description": "При включении этой функции нажатие клавиши-модификатора (Shift, Ctrl, Alt и т. д.) на виртуальной клавиатуре удерживает её до повторного нажатия. При отключении модификаторы отпускаются немедленно, как обычные клавиши.", + "keyboard_modifier_latching_title": "Залипание клавиш-модификаторов", + "keyboard_quick_actions_description": "Быстрые действия", "keyboard_show_pressed_keys_description": "Отображать текущие нажатые клавиши в строке состояния", "keyboard_show_pressed_keys_title": "Показать нажатые клавиши", "keyboard_title": "Клавиатура", + "keys_alt": "Альт", + "keys_altgr": "АльтГр", + "keys_application": "Приложение", + "keys_arrow_down": "Стрела вниз", + "keys_arrow_left": "Стрелка влево", + "keys_arrow_right": "Стрелка вправо", + "keys_arrow_up": "Стрелка вверх", + "keys_backspace": "Backspace", + "keys_caps_lock": "Caps Lock", + "keys_command": "Командование", + "keys_control": "Контроль", + "keys_delete": "Удалить", + "keys_end": "Конец", + "keys_enter": "Входить", + "keys_escape": "Побег", + "keys_home": "Дом", + "keys_insert": "Вставлять", + "keys_menu": "Меню", + "keys_meta": "Мета", + "keys_modifier_altgr": "АльтГр", + "keys_modifier_altgr_shift": "Shift+AltGr", + "keys_modifier_control": "Контроль", + "keys_modifier_control_shift": "Shift+Control", + "keys_modifier_kana": "Кана", + "keys_modifier_kana_shift": "Shift+Кана", + "keys_modifier_shift": "Сдвиг", + "keys_num_lock": "Num Lock", + "keys_option": "Вариант", + "keys_page_down": "Вниз страницы", + "keys_page_up": "Страница вверх", + "keys_pause": "Пауза", + "keys_print_screen": "Скриншот", + "keys_scroll_lock": "Блокировка прокрутки", + "keys_shift": "Сдвиг", + "keys_space": "Космос", + "keys_tab": "Вкладка", "kvm_terminal": "Терминал KVM", "last_online": "Последний раз в сети {time}", "learn_more": "Узнать больше", diff --git a/ui/localization/messages/sv.json b/ui/localization/messages/sv.json index ddd9d3db6..1588a72d4 100644 --- a/ui/localization/messages/sv.json +++ b/ui/localization/messages/sv.json @@ -416,15 +416,77 @@ "jiggler_save_jiggler_config": "Spara Jiggler-konfiguration", "jiggler_timezone_description": "Tidszon för cron-schema", "jiggler_timezone_label": "Tidszon", + "keyboard_combo_explain_close_window": "Stäng fönstret", + "keyboard_combo_explain_kill_x11": "Döda X11", + "keyboard_combo_explain_lock": "Lås arbetsstationen", + "keyboard_combo_explain_run_dialog": "Kör dialogruta", + "keyboard_combo_explain_spotlight": "Strålkastare", + "keyboard_combo_explain_task_manager": "Aktivitetshanteraren", "keyboard_description": "Konfigurera tangentbordsinställningar för din enhet", + "keyboard_layout_custom_description": "Användaruppladdade tangentbordslayouter", + "keyboard_layout_custom_preview": "Förhandsvisning", + "keyboard_layout_custom_replace": "Ersätta", + "keyboard_layout_custom_title": "Anpassade layouter", + "keyboard_layout_custom_upload": "Ladda upp layout", + "keyboard_layout_custom_upload_error": "Uppladdningen misslyckades: {error}", + "keyboard_layout_custom_upload_success": "Layout \" {name} \" uppladdad ( {keys} nycklar)", + "keyboard_layout_delete_confirm_description": "Är du säker på att du vill ta bort layouten \" {name} \"? Detta kan inte ångras.", + "keyboard_layout_delete_confirm_title": "Ta bort tangentbordslayout", + "keyboard_layout_delete_error": "Misslyckades med att ta bort layout: {error}", + "keyboard_layout_delete_success": "Layout \" {name} \" borttagen", "keyboard_layout_description": "Tangentbordslayout för måloperativsystemet", "keyboard_layout_error": "Misslyckades med att ställa in tangentbordslayout: {error}", "keyboard_layout_long_description": "Det virtuella tangentbordet, textklistringen och tangentbordsmakron skickar individuella tangenttryckningar till målenheten. Tangentbordslayouten avgör vilka tangentkoder som skickas. Se till att tangentbordslayouten i JetKVM matchar inställningarna i operativsystemet.", + "keyboard_layout_none_custom": "Inga anpassade layouter har laddats upp", + "keyboard_layout_preview_chars": "{count} mappade tecken", + "keyboard_layout_preview_close": "Nära", + "keyboard_layout_preview_keys": "{count} nycklar", + "keyboard_layout_preview_use": "Använd den här layouten", + "keyboard_layout_preview_using": "Aktiv", "keyboard_layout_success": "Tangentbordslayouten har ställts in på {layout}", "keyboard_layout_title": "Tangentbordslayout", + "keyboard_modifier_latching_description": "När den är aktiverad håller du ner en modifieringstangent (Shift, Ctrl, Alt, etc.) på det virtuella tangentbordet tills du klickar på den igen. När den är inaktiverad släpps modifieringstangenterna omedelbart som vanliga tangenter.", + "keyboard_modifier_latching_title": "Klibbiga modifieringstangenter", + "keyboard_quick_actions_description": "Snabbåtgärder", "keyboard_show_pressed_keys_description": "Visa nedtryckta tangenter i statusfältet", "keyboard_show_pressed_keys_title": "Visa nedtryckta tangenter", "keyboard_title": "Tangentbord", + "keys_alt": "Alt", + "keys_altgr": "AltGr", + "keys_application": "Ansökan", + "keys_arrow_down": "Pil nedåt", + "keys_arrow_left": "Pil vänster", + "keys_arrow_right": "Pil höger", + "keys_arrow_up": "Pil upp", + "keys_backspace": "Backsteg", + "keys_caps_lock": "Caps Lock", + "keys_command": "Kommando", + "keys_control": "Kontrollera", + "keys_delete": "Radera", + "keys_end": "Avsluta", + "keys_enter": "Skriva in", + "keys_escape": "Fly", + "keys_home": "Hem", + "keys_insert": "Infoga", + "keys_menu": "Meny", + "keys_meta": "Meta", + "keys_modifier_altgr": "AltGr", + "keys_modifier_altgr_shift": "Skift+AltGr", + "keys_modifier_control": "Kontrollera", + "keys_modifier_control_shift": "Skift+Kontroll", + "keys_modifier_kana": "Kana", + "keys_modifier_kana_shift": "Skift+Kana", + "keys_modifier_shift": "Flytta", + "keys_num_lock": "Num Lock", + "keys_option": "Alternativ", + "keys_page_down": "Sida ner", + "keys_page_up": "Sida upp", + "keys_pause": "Paus", + "keys_print_screen": "Skriv ut skärm", + "keys_scroll_lock": "Scroll Lock", + "keys_shift": "Flytta", + "keys_space": "Utrymme", + "keys_tab": "Flik", "kvm_terminal": "KVM-terminal", "last_online": "Senast online {time}", "learn_more": "Läs mer", diff --git a/ui/localization/messages/zh-tw.json b/ui/localization/messages/zh-tw.json index c16e6c723..98b5e4e58 100644 --- a/ui/localization/messages/zh-tw.json +++ b/ui/localization/messages/zh-tw.json @@ -416,15 +416,77 @@ "jiggler_save_jiggler_config": "儲存防休眠設定", "jiggler_timezone_description": "Cron 排程的時區", "jiggler_timezone_label": "時區", + "keyboard_combo_explain_close_window": "關閉視窗", + "keyboard_combo_explain_kill_x11": "殺戮 X11", + "keyboard_combo_explain_lock": "鎖定工作站", + "keyboard_combo_explain_run_dialog": "運行對話框", + "keyboard_combo_explain_spotlight": "聚光燈", + "keyboard_combo_explain_task_manager": "工作管理員", "keyboard_description": "設定您裝置的鍵盤設定", + "keyboard_layout_custom_description": "用戶上傳的鍵盤佈局", + "keyboard_layout_custom_preview": "預覽", + "keyboard_layout_custom_replace": "代替", + "keyboard_layout_custom_title": "自訂佈局", + "keyboard_layout_custom_upload": "上傳佈局", + "keyboard_layout_custom_upload_error": "上傳失敗: {error}", + "keyboard_layout_custom_upload_success": "版面配置「 {name} \" 上傳( {keys}鍵)", + "keyboard_layout_delete_confirm_description": "您確定要刪除佈局「 {name} \"嗎?此操作無法撤銷。", + "keyboard_layout_delete_confirm_title": "刪除鍵盤佈局", + "keyboard_layout_delete_error": "刪除佈局失敗: {error}", + "keyboard_layout_delete_success": "版面配置「 {name} \" 已刪除", "keyboard_layout_description": "目標作業系統的鍵盤配置", "keyboard_layout_error": "設定鍵盤配置失敗:{error}", "keyboard_layout_long_description": "虛擬鍵盤、貼上文字和鍵盤巨集會傳送個別按鍵至目標裝置。鍵盤配置決定了傳送哪些鍵碼。請確保 JetKVM 中的鍵盤配置與作業系統中的設定相符。", + "keyboard_layout_none_custom": "未上傳任何自訂佈局", + "keyboard_layout_preview_chars": "{count}個字元已映射", + "keyboard_layout_preview_close": "關閉", + "keyboard_layout_preview_keys": "{count}鍵", + "keyboard_layout_preview_use": "使用此佈局", + "keyboard_layout_preview_using": "積極的", "keyboard_layout_success": "鍵盤配置已成功設定為 {layout}", "keyboard_layout_title": "鍵盤配置", + "keyboard_modifier_latching_description": "啟用此功能後,按一下虛擬鍵盤上的修飾鍵(Shift、Ctrl、Alt 等)會將其保持按下狀態,直到再次按一下為止。停用此功能後,修飾鍵會像普通按鍵一樣立即釋放。", + "keyboard_modifier_latching_title": "黏滯修飾鍵", + "keyboard_quick_actions_description": "快速操作", "keyboard_show_pressed_keys_description": "在狀態列顯示目前按下的按鍵", "keyboard_show_pressed_keys_title": "顯示按鍵狀態", "keyboard_title": "鍵盤", + "keys_alt": "另類", + "keys_altgr": "AltGr", + "keys_application": "應用", + "keys_arrow_down": "向下箭頭", + "keys_arrow_left": "左箭頭", + "keys_arrow_right": "向右箭頭", + "keys_arrow_up": "向上箭頭", + "keys_backspace": "退格鍵", + "keys_caps_lock": "大寫鎖定", + "keys_command": "命令", + "keys_control": "控制", + "keys_delete": "刪除", + "keys_end": "結尾", + "keys_enter": "進入", + "keys_escape": "逃脫", + "keys_home": "家", + "keys_insert": "插入", + "keys_menu": "選單", + "keys_meta": "元", + "keys_modifier_altgr": "AltGr", + "keys_modifier_altgr_shift": "Shift+AltGr", + "keys_modifier_control": "控制", + "keys_modifier_control_shift": "Shift+Control", + "keys_modifier_kana": "假名", + "keys_modifier_kana_shift": "Shift+假名", + "keys_modifier_shift": "轉移", + "keys_num_lock": "數字鎖定鍵", + "keys_option": "選項", + "keys_page_down": "往下翻頁", + "keys_page_up": "上一頁", + "keys_pause": "暫停", + "keys_print_screen": "螢幕截圖", + "keys_scroll_lock": "滾動鎖定", + "keys_shift": "轉移", + "keys_space": "空間", + "keys_tab": "標籤頁", "kvm_terminal": "KVM 終端機", "last_online": "最後上線於 {time}", "learn_more": "了解更多", diff --git a/ui/localization/messages/zh.json b/ui/localization/messages/zh.json index 1095a795b..d454fed8a 100644 --- a/ui/localization/messages/zh.json +++ b/ui/localization/messages/zh.json @@ -416,15 +416,77 @@ "jiggler_save_jiggler_config": "保存防休眠配置", "jiggler_timezone_description": "Cron 计划所使用的时区。", "jiggler_timezone_label": "时区", + "keyboard_combo_explain_close_window": "关闭窗口", + "keyboard_combo_explain_kill_x11": "杀戮 X11", + "keyboard_combo_explain_lock": "锁定工作站", + "keyboard_combo_explain_run_dialog": "运行对话框", + "keyboard_combo_explain_spotlight": "聚光灯", + "keyboard_combo_explain_task_manager": "任务管理器", "keyboard_description": "为您的设备配置键盘相关设置。", + "keyboard_layout_custom_description": "用户上传的键盘布局", + "keyboard_layout_custom_preview": "预览", + "keyboard_layout_custom_replace": "代替", + "keyboard_layout_custom_title": "自定义布局", + "keyboard_layout_custom_upload": "上传布局", + "keyboard_layout_custom_upload_error": "上传失败: {error}", + "keyboard_layout_custom_upload_success": "布局“ {name} \" 已上传( {keys}键)", + "keyboard_layout_delete_confirm_description": "您确定要删除布局“ {name} \"吗?此操作无法撤销。", + "keyboard_layout_delete_confirm_title": "删除键盘布局", + "keyboard_layout_delete_error": "删除布局失败: {error}", + "keyboard_layout_delete_success": "布局“ {name} \" 已删除", "keyboard_layout_description": "目标操作系统的键盘布局。", "keyboard_layout_error": "设置键盘布局失败:{error}", "keyboard_layout_long_description": "虚拟键盘、文本粘贴和键盘宏功能会将独立的按键指令发送到目标设备。键盘布局决定了发送的键码。请确保 JetKVM 中设置的键盘布局与目标操作系统的设置相匹配。", + "keyboard_layout_none_custom": "未上传任何自定义布局", + "keyboard_layout_preview_chars": "{count}个字符已映射", + "keyboard_layout_preview_close": "关闭", + "keyboard_layout_preview_keys": "{count}键", + "keyboard_layout_preview_use": "使用此布局", + "keyboard_layout_preview_using": "积极的", "keyboard_layout_success": "键盘布局已成功设置为 {layout}", "keyboard_layout_title": "键盘布局", + "keyboard_modifier_latching_description": "启用此功能后,单击虚拟键盘上的修饰键(Shift、Ctrl、Alt 等)会将其保持按下状态,直到再次单击为止。禁用此功能后,修饰键会像普通按键一样立即释放。", + "keyboard_modifier_latching_title": "粘滞修饰键", + "keyboard_quick_actions_description": "快速操作", "keyboard_show_pressed_keys_description": "在状态栏中显示当前按下的按键。", "keyboard_show_pressed_keys_title": "显示按键状态", "keyboard_title": "键盘", + "keys_alt": "另类", + "keys_altgr": "AltGr", + "keys_application": "应用", + "keys_arrow_down": "向下箭头", + "keys_arrow_left": "左箭头", + "keys_arrow_right": "向右箭头", + "keys_arrow_up": "向上箭头", + "keys_backspace": "退格键", + "keys_caps_lock": "大写锁定", + "keys_command": "命令", + "keys_control": "控制", + "keys_delete": "删除", + "keys_end": "结尾", + "keys_enter": "进入", + "keys_escape": "逃脱", + "keys_home": "家", + "keys_insert": "插入", + "keys_menu": "菜单", + "keys_meta": "元", + "keys_modifier_altgr": "AltGr", + "keys_modifier_altgr_shift": "Shift+AltGr", + "keys_modifier_control": "控制", + "keys_modifier_control_shift": "Shift+Control", + "keys_modifier_kana": "假名", + "keys_modifier_kana_shift": "Shift+假名", + "keys_modifier_shift": "转移", + "keys_num_lock": "数字锁定键", + "keys_option": "选项", + "keys_page_down": "向下翻页", + "keys_page_up": "上一页", + "keys_pause": "暂停", + "keys_print_screen": "屏幕截图", + "keys_scroll_lock": "滚动锁定", + "keys_shift": "转移", + "keys_space": "空间", + "keys_tab": "标签页", "kvm_terminal": "KVM 终端", "last_online": "最后在线于 {time}", "learn_more": "了解更多", diff --git a/ui/package-lock.json b/ui/package-lock.json index 3553c7814..9b46b84a8 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,14 +1,14 @@ { "name": "kvm-ui", - "version": "2025.12.11.1200", + "version": "2026.05.05.1730", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kvm-ui", - "version": "2025.12.11.1200", + "version": "2026.05.05.1730", "dependencies": { - "@headlessui/react": "^2.2.9", + "@headlessui/react": "^2.2.10", "@headlessui/tailwindcss": "^0.2.2", "@heroicons/react": "^2.2.0", "@vitejs/plugin-basic-ssl": "^2.3.0", @@ -19,56 +19,55 @@ "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", "cva": "^1.0.0-beta.4", - "dayjs": "^1.11.19", - "focus-trap-react": "^11.0.4", - "framer-motion": "^12.23.26", + "dayjs": "^1.11.20", + "focus-trap-react": "^12.0.1", + "framer-motion": "^12.38.0", "mini-svg-data-uri": "^1.4.4", - "react": "^19.2.3", + "react": "^19.2.5", "react-animate-height": "^3.2.3", - "react-dom": "^19.2.3", - "react-hook-form": "^7.68.0", + "react-dom": "^19.2.5", + "react-hook-form": "^7.75.0", "react-hot-toast": "^2.6.0", - "react-icons": "^5.5.0", - "react-router": "^7.10.1", - "react-simple-keyboard": "^3.8.141", + "react-icons": "^5.6.0", + "react-router": "^7.15.0", "react-use-websocket": "^4.13.0", - "react-xtermjs": "^1.0.10", - "recharts": "^3.5.1", - "semver": "^7.7.3", - "tailwind-merge": "^3.4.0", + "react-xtermjs": "^1.0.11", + "recharts": "^3.8.1", + "semver": "^7.7.4", + "tailwind-merge": "^3.5.0", "tesseract.js": "^7.0.0", "tslog": "^4.10.2", "usehooks-ts": "^3.1.1", - "validator": "^13.15.23", + "validator": "^13.15.35", "zustand": "^4.5.2" }, "devDependencies": { - "@inlang/cli": "^3.0.12", - "@inlang/paraglide-js": "^2.6.0", - "@inlang/plugin-m-function-matcher": "^2.1.0", - "@inlang/plugin-message-format": "^4.0.0", - "@inlang/sdk": "^2.4.9", - "@playwright/test": "^1.49.0", - "@tailwindcss/forms": "^0.5.10", - "@tailwindcss/postcss": "^4.1.18", + "@inlang/cli": "^3.1.11", + "@inlang/paraglide-js": "^2.18.0", + "@inlang/plugin-m-function-matcher": "^2.2.6", + "@inlang/plugin-message-format": "^4.4.0", + "@inlang/sdk": "^2.9.3", + "@playwright/test": "^1.59.1", + "@tailwindcss/forms": "^0.5.11", + "@tailwindcss/postcss": "^4.2.4", "@tailwindcss/typography": "^0.5.19", - "@tailwindcss/vite": "^4.2.2", - "@types/node": "^24.10.1", - "@types/react": "^19.2.7", + "@tailwindcss/vite": "^4.2.4", + "@types/node": "^25.6.0", + "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/semver": "^7.7.1", "@types/validator": "^13.15.10", "@vitejs/plugin-react-swc": "^4.3.0", - "autoprefixer": "^10.4.23", + "autoprefixer": "^10.5.0", "husky": "^9.1.7", "lint-staged": "^16.4.0", - "oxfmt": "^0.42.0", - "oxlint": "^1.57.0", - "oxlint-tsgolint": "^0.18.1", - "postcss": "^8.5.6", - "tailwindcss": "^4.1.18", + "oxfmt": "^0.48.0", + "oxlint": "^1.63.0", + "oxlint-tsgolint": "^0.22.1", + "postcss": "^8.5.14", + "tailwindcss": "^4.2.4", "typescript": "^5.9.3", - "vite": "^8.0.3" + "vite": "^8.0.10" }, "engines": { "node": "^22.21.1" @@ -88,20 +87,20 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", - "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", - "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "license": "MIT", "optional": true, "dependencies": { @@ -109,9 +108,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "license": "MIT", "optional": true, "dependencies": { @@ -119,22 +118,22 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", - "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.10" + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", - "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.3", - "@floating-ui/utils": "^0.2.10" + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/react": { @@ -153,12 +152,12 @@ } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", - "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.7.4" + "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", @@ -166,15 +165,15 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", - "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, "node_modules/@headlessui/react": { - "version": "2.2.9", - "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.9.tgz", - "integrity": "sha512-Mb+Un58gwBn0/yWZfyrCh0TJyurtT+dETj7YHleylHk5od3dv2XqETPGWMyQ5/7sYN7oWdyM1u9MvC0OC8UmzQ==", + "version": "2.2.10", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.10.tgz", + "integrity": "sha512-5pVLNK9wlpxTUTy9GpgbX/SdcRh+HBnPktjM2wbiLTH4p+2EPHBO1aoSryUCuKUIItdDWO9ITlhUL8UnUN/oIA==", "license": "MIT", "dependencies": { "@floating-ui/react": "^0.26.16", @@ -213,13 +212,13 @@ } }, "node_modules/@inlang/cli": { - "version": "3.0.12", - "resolved": "https://registry.npmjs.org/@inlang/cli/-/cli-3.0.12.tgz", - "integrity": "sha512-0FZJtgrt1Ol4iwKtA0VICrsHcA3stWTSP2jq8mpTgjTlFU63gr5JcyFljUT8Dp5nDIJYmdh3kJ0a8PhW0X8clQ==", + "version": "3.1.11", + "resolved": "https://registry.npmjs.org/@inlang/cli/-/cli-3.1.11.tgz", + "integrity": "sha512-GRM+DT0nnFYVPs/lK85YvX5CeUQ/rWvxKdvnpBb0xlAcamhYEVb7VpHVfu26Ec1/wWtHOVIM1POguiytrm2BIw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@inlang/sdk": "2.4.9", + "@inlang/sdk": "2.9.3", "esbuild-wasm": "^0.19.2" }, "bin": { @@ -230,14 +229,14 @@ } }, "node_modules/@inlang/paraglide-js": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@inlang/paraglide-js/-/paraglide-js-2.6.0.tgz", - "integrity": "sha512-TqbgvLD2TrOgyGQULMCQvgOjHtKCFrILtCWa+ljRhVvtbve4yq4NeX+6rNKIpxxEQLraHMQRZSuP4FNSyYX5BA==", + "version": "2.18.0", + "resolved": "https://registry.npmjs.org/@inlang/paraglide-js/-/paraglide-js-2.18.0.tgz", + "integrity": "sha512-Acp6htA5W7rS2kL3iMjhSr08eECz696MYsud+FBKrmahzM7PdywPVq9UOr9MaC/aV7AZPElrYcqYZOOlUri5fg==", "dev": true, "license": "MIT", "dependencies": { - "@inlang/recommend-sherlock": "0.2.1", - "@inlang/sdk": "2.4.9", + "@inlang/recommend-sherlock": "^0.2.1", + "@inlang/sdk": "^2.9.3", "commander": "11.1.0", "consola": "3.4.0", "json5": "2.2.3", @@ -246,21 +245,29 @@ }, "bin": { "paraglide-js": "bin/run.js" + }, + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@inlang/plugin-m-function-matcher": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@inlang/plugin-m-function-matcher/-/plugin-m-function-matcher-2.1.0.tgz", - "integrity": "sha512-IAbG7rOl+rlTiZY7qj92we6lmII693lVthPtY9bFDkZ/Ig7FPSpae/TfLzqjf2KcR1nDdx1zRpSo6roDPeM85g==", + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@inlang/plugin-m-function-matcher/-/plugin-m-function-matcher-2.2.6.tgz", + "integrity": "sha512-sEDVWjY8WgDq0mkEy117IZwYuzZHUZsnCV0IiRfheAW03mbnWAK/eOlECjxncKxWdQOpT0QI7rebvZRZFu4t8w==", "dev": true, "dependencies": { - "@inlang/sdk": "2.4.9" + "@inlang/sdk": "2.9.3" } }, "node_modules/@inlang/plugin-message-format": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@inlang/plugin-message-format/-/plugin-message-format-4.0.0.tgz", - "integrity": "sha512-zNpLxLTt+bDd3JLXj1ONzo+Q6AOzz2MfcgGo8XB6/bweGhFIndK3GU/q0iU4o7VI4KS1+OHNLpKwFcrAifwERQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@inlang/plugin-message-format/-/plugin-message-format-4.4.0.tgz", + "integrity": "sha512-n4aXt6XVg5kxhKoLAhi9nMgZtCA9iS0QOaXte56VqxWHcfj9O4c4gOkyVQZH7H9D8h7OZufCrO1sZGYOypPwEA==", "dev": true, "dependencies": { "flat": "^6.0.1" @@ -277,20 +284,47 @@ } }, "node_modules/@inlang/sdk": { - "version": "2.4.9", - "resolved": "https://registry.npmjs.org/@inlang/sdk/-/sdk-2.4.9.tgz", - "integrity": "sha512-cvz/C1rF5WBxzHbEoiBoI6Sz6q6M+TdxfWkEGBYTD77opY8i8WN01prUWXEM87GPF4SZcyIySez9U0Ccm12oFQ==", + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/@inlang/sdk/-/sdk-2.9.3.tgz", + "integrity": "sha512-E/SxcSji8WIt4DqQG9APlOs6tVtJxrrOUS3dE4ho3pWRCLLIY0PIVzgNwSukuFT+m8LuJDFwpRY5VY3ryzyGWQ==", "dev": true, "license": "MIT", "dependencies": { - "@lix-js/sdk": "0.4.7", + "@lix-js/sdk": "0.4.10", "@sinclair/typebox": "^0.31.17", - "kysely": "^0.27.4", + "kysely": "^0.28.12", "sqlite-wasm-kysely": "0.3.0", - "uuid": "^10.0.0" + "uuid": "^14.0.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" + } + }, + "node_modules/@internationalized/date": { + "version": "3.12.1", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.1.tgz", + "integrity": "sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@internationalized/number": { + "version": "3.6.6", + "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.6.tgz", + "integrity": "sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@internationalized/string": { + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/@internationalized/string/-/string-3.2.8.tgz", + "integrity": "sha512-NdbMQUSfXLYIQol5VyMtinm9pZDciiMfN7RtmSuSB78io1hqwJ0naYfxyW6vgxWBkzWymQa/3uLDlbfmshtCaA==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" } }, "node_modules/@jridgewell/gen-mapping": { @@ -344,9 +378,9 @@ } }, "node_modules/@lix-js/sdk": { - "version": "0.4.7", - "resolved": "https://registry.npmjs.org/@lix-js/sdk/-/sdk-0.4.7.tgz", - "integrity": "sha512-pRbW+joG12L0ULfMiWYosIW0plmW4AsUdiPCp+Z8rAsElJ+wJ6in58zhD3UwUcd4BNcpldEGjg6PdA7e0RgsDQ==", + "version": "0.4.10", + "resolved": "https://registry.npmjs.org/@lix-js/sdk/-/sdk-0.4.10.tgz", + "integrity": "sha512-0dMInAJK/67guTG5rRZaCEhvzC5cCXENOjaePA5AqMXrCE97kaY7SRor9e2vnoGsFIiGqXKlT0MCIoZj36G0gg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -354,9 +388,9 @@ "dedent": "1.5.1", "human-id": "^4.1.1", "js-sha256": "^0.11.0", - "kysely": "^0.27.4", + "kysely": "^0.28.12", "sqlite-wasm-kysely": "0.3.0", - "uuid": "^10.0.0" + "uuid": "^14.0.0" }, "engines": { "node": ">=18" @@ -370,34 +404,36 @@ "license": "Apache-2.0" }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@oxc-project/types": { - "version": "0.122.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", - "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" } }, "node_modules/@oxfmt/binding-android-arm-eabi": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.42.0.tgz", - "integrity": "sha512-dsqPTYsozeokRjlrt/b4E7Pj0z3eS3Eg74TWQuuKbjY4VttBmA88rB7d50Xrd+TZ986qdXCNeZRPEzZHAe+jow==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.48.0.tgz", + "integrity": "sha512-uwqk+/KhQvBIpULD8SMM/zAafMRC/+DV/xsEQjkkIsJ/kLmEI/2bxonVowcYTiXqqZ/a0FEW8DPkZY3VvwELDA==", "cpu": [ "arm" ], @@ -412,9 +448,9 @@ } }, "node_modules/@oxfmt/binding-android-arm64": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.42.0.tgz", - "integrity": "sha512-t+aAjHxcr5eOBphFHdg1ouQU9qmZZoRxnX7UOJSaTwSoKsb6TYezNKO0YbWytGXCECObRqNcUxPoPr0KaraAIg==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.48.0.tgz", + "integrity": "sha512-VUCiKuXK5+McVssgHEJdrcGK7hRJzrRb36zm9/jwzMholyYt4BgXhw5Nm1V1DX6Ce717Zi/1jk432b/tgmQgtQ==", "cpu": [ "arm64" ], @@ -429,9 +465,9 @@ } }, "node_modules/@oxfmt/binding-darwin-arm64": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.42.0.tgz", - "integrity": "sha512-ulpSEYMKg61C5bRMZinFHrKJYRoKGVbvMEXA5zM1puX3O9T6Q4XXDbft20yrDijpYWeuG59z3Nabt+npeTsM1A==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.48.0.tgz", + "integrity": "sha512-IkKp8rnIyQLW6Jt+6jragCbUVYSayk55lapiprLjIVvt4NczLyO/nwX2GgefLQ5iaBdfS8UEAFgCs/pLO6Cl0w==", "cpu": [ "arm64" ], @@ -446,9 +482,9 @@ } }, "node_modules/@oxfmt/binding-darwin-x64": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.42.0.tgz", - "integrity": "sha512-ttxLKhQYPdFiM8I/Ri37cvqChE4Xa562nNOsZFcv1CKTVLeEozXjKuYClNvxkXmNlcF55nzM80P+CQkdFBu+uQ==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.48.0.tgz", + "integrity": "sha512-+aFuhsGIuvnoOjXyKVHMhPKJZR1kQkAl8QyrKoMlA7yJsSTC3N0Asl53La8TChSHhW8epToQ/Q0nvLmEmfNmLg==", "cpu": [ "x64" ], @@ -463,9 +499,9 @@ } }, "node_modules/@oxfmt/binding-freebsd-x64": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.42.0.tgz", - "integrity": "sha512-Og7QS3yI3tdIKYZ58SXik0rADxIk2jmd+/YvuHRyKULWpG4V2fR5V4hvKm624Mc0cQET35waPXiCQWvjQEjwYQ==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.48.0.tgz", + "integrity": "sha512-fbqzQL8FjI9gGnktI7RIo0dksDziTAYBy7xlI7jU7eID5fxLF/25fS4Xj6GydD8Y5oWHL83U4NK160QaOAxtyg==", "cpu": [ "x64" ], @@ -480,9 +516,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.42.0.tgz", - "integrity": "sha512-jwLOw/3CW4H6Vxcry4/buQHk7zm9Ne2YsidzTL1kpiMe4qqrRCwev3dkyWe2YkFmP+iZCQ7zku4KwjcLRoh8ew==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.48.0.tgz", + "integrity": "sha512-hn4i0zhAyTiB3ZHjQfYUZkDvrbVkohw1S7pySWxWUoZ87HnkDoTFThj7QTxk40hNPOTUP0vHbPRNamFIv1HBJQ==", "cpu": [ "arm" ], @@ -497,9 +533,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm-musleabihf": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.42.0.tgz", - "integrity": "sha512-XwXu2vkMtiq2h7tfvN+WA/9/5/1IoGAVCFPiiQUvcAuG3efR97KNcRGM8BetmbYouFotQ2bDal3yyjUx6IPsTg==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.48.0.tgz", + "integrity": "sha512-R4WBD9qF3QM9hqgdAa+fBGXmquTvDUujrPQ36t2Sjk8RPOSKGHDeN7l/khr10hqbQaOq9KCgPHG9ubNET/X/RQ==", "cpu": [ "arm" ], @@ -514,9 +550,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm64-gnu": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.42.0.tgz", - "integrity": "sha512-ea7s/XUJoT7ENAtUQDudFe3nkSM3e3Qpz4nJFRdzO2wbgXEcjnchKLEsV3+t4ev3r8nWxIYr9NRjPWtnyIFJVA==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.48.0.tgz", + "integrity": "sha512-5bVdwSwlm1M8wbYCorLOxWxUBw/8tBvHYyQNIfwWVPwOJaj5vg1APSGJQVpwJfV5VNE9PSrR91UKEpoNwHhqUA==", "cpu": [ "arm64" ], @@ -531,9 +567,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm64-musl": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.42.0.tgz", - "integrity": "sha512-+JA0YMlSdDqmacygGi2REp57c3fN+tzARD8nwsukx9pkCHK+6DkbAA9ojS4lNKsiBjIW8WWa0pBrBWhdZEqfuw==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.48.0.tgz", + "integrity": "sha512-vCS3Fk7gFslTqE1lUE2IlroyVV7u/9SmMA/uBqDoshuck2psGWcjW0ePyPZI3rM3+qtf2pDaMVIKMHozraifuw==", "cpu": [ "arm64" ], @@ -548,9 +584,9 @@ } }, "node_modules/@oxfmt/binding-linux-ppc64-gnu": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.42.0.tgz", - "integrity": "sha512-VfnET0j4Y5mdfCzh5gBt0NK28lgn5DKx+8WgSMLYYeSooHhohdbzwAStLki9pNuGy51y4I7IoW8bqwAaCMiJQg==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.48.0.tgz", + "integrity": "sha512-gKtfFfueUClXDumyoHUbymqRf7prHejOOyzJK0eIJn93GF9JBdFHdo60TM1ZBHxkEwZvjuOgHmKtneKbEOc/Eg==", "cpu": [ "ppc64" ], @@ -565,9 +601,9 @@ } }, "node_modules/@oxfmt/binding-linux-riscv64-gnu": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.42.0.tgz", - "integrity": "sha512-gVlCbmBkB0fxBWbhBj9rcxezPydsQHf4MFKeHoTSPicOQ+8oGeTQgQ8EeesSybWeiFPVRx3bgdt4IJnH6nOjAA==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.48.0.tgz", + "integrity": "sha512-SYt0UhOvZD/UwZz9sXq6J2uAw8o24f5VZpLB2DH01f6MevshmlgakQlZe2lwek2sZJkd07eLu7mZa0g7yeiw7Q==", "cpu": [ "riscv64" ], @@ -582,9 +618,9 @@ } }, "node_modules/@oxfmt/binding-linux-riscv64-musl": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.42.0.tgz", - "integrity": "sha512-zN5OfstL0avgt/IgvRu0zjQzVh/EPkcLzs33E9LMAzpqlLWiPWeMDZyMGFlSRGOdDjuNmlZBCgj0pFnK5u32TQ==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.48.0.tgz", + "integrity": "sha512-JLbrwck2AopG4ud/XklZO5N+qxGC7cS7ROvXZVNfx0MCLDDL2kGOLvzuWORkVjnjAM0CMAfIMU2zNBtQbM+4dw==", "cpu": [ "riscv64" ], @@ -599,9 +635,9 @@ } }, "node_modules/@oxfmt/binding-linux-s390x-gnu": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.42.0.tgz", - "integrity": "sha512-9X6+H2L0qMc2sCAgO9HS03bkGLMKvOFjmEdchaFlany3vNZOjnVui//D8k/xZAtQv2vaCs1reD5KAgPoIU4msA==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.48.0.tgz", + "integrity": "sha512-mdxt5L8OQLxkQH+JVpdC/lknZNe0lX4hlO3d8+xvw2wToo+iDrid9tiGOd5bmHfUVd5wVhrUry0qlu5vq66NkQ==", "cpu": [ "s390x" ], @@ -616,9 +652,9 @@ } }, "node_modules/@oxfmt/binding-linux-x64-gnu": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.42.0.tgz", - "integrity": "sha512-BajxJ6KQvMMdpXGPWhBGyjb2Jvx4uec0w+wi6TJZ6Tv7+MzPwe0pO8g5h1U0jyFgoaF7mDl6yKPW3ykWcbUJRw==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.48.0.tgz", + "integrity": "sha512-oEz1BQwMrV7OMEFx/3VPDU3n9TM0AnxpktDYXjEg5i6nTX87wo18wSfBvkl4tzAICdKtoAQAdBIl7Y7hsPlx5w==", "cpu": [ "x64" ], @@ -633,9 +669,9 @@ } }, "node_modules/@oxfmt/binding-linux-x64-musl": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.42.0.tgz", - "integrity": "sha512-0wV284I6vc5f0AqAhgAbHU2935B4bVpncPoe5n/WzVZY/KnHgqxC8iSFGeSyLWEgstFboIcWkOPck7tqbdHkzA==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.48.0.tgz", + "integrity": "sha512-g2SKTTurP5mWjd8Ecait0erYqmltL4IqW1EwttM25BxM6NiTt4ubobJYMR1uox1V2QgG4UfHH10CGRvWlUixjw==", "cpu": [ "x64" ], @@ -650,9 +686,9 @@ } }, "node_modules/@oxfmt/binding-openharmony-arm64": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.42.0.tgz", - "integrity": "sha512-p4BG6HpGnhfgHk1rzZfyR6zcWkE7iLrWxyehHfXUy4Qa5j3e0roglFOdP/Nj5cJJ58MA3isQ5dlfkW2nNEpolw==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.48.0.tgz", + "integrity": "sha512-CIg24VgheEpvolHL2gQuax5qcQ602bRMHrJ9g8XsQr3iVj9aSPgopigBKuMqrXsupwkrU+RQCn5cG8PgFntR6w==", "cpu": [ "arm64" ], @@ -667,9 +703,9 @@ } }, "node_modules/@oxfmt/binding-win32-arm64-msvc": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.42.0.tgz", - "integrity": "sha512-mn//WV60A+IetORDxYieYGAoQso4KnVRRjORDewMcod4irlRe0OSC7YPhhwaexYNPQz/GCFk+v9iUcZ2W22yxQ==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.48.0.tgz", + "integrity": "sha512-zeaWkcxcEULwkGF3I/HgEvcDPN8buYDrxibBUa/IFh5Vmwyge+KpLO+hEwSovW349H0O/C0Z2kaFmEzEDm00/Q==", "cpu": [ "arm64" ], @@ -684,9 +720,9 @@ } }, "node_modules/@oxfmt/binding-win32-ia32-msvc": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.42.0.tgz", - "integrity": "sha512-3gWltUrvuz4LPJXWivoAxZ28Of2O4N7OGuM5/X3ubPXCEV8hmgECLZzjz7UYvSDUS3grfdccQwmjynm+51EFpw==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.48.0.tgz", + "integrity": "sha512-yiEKnIAGvx5CyZQOlMaNlZkAbwT7/Quk0j3WLt+PR5hK+qYjPTRRJYDfD77wCBPLvEYAG41v4KG3iL0H+uxoxg==", "cpu": [ "ia32" ], @@ -701,9 +737,9 @@ } }, "node_modules/@oxfmt/binding-win32-x64-msvc": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.42.0.tgz", - "integrity": "sha512-Wg4TMAfQRL9J9AZevJ/ZNy3uyyDztDYQtGr4P8UyyzIhLhFrdSmz1J/9JT+rv0fiCDLaFOBQnj3f3K3+a5PzDQ==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.48.0.tgz", + "integrity": "sha512-GSD2+7t2UoVMV2NgxXypa4bKewflPMAjYnF0Xw9/ht82ZfafAHhb8STwrEd7wlH2PFogt5zw3WVCxYJaHUdbeQ==", "cpu": [ "x64" ], @@ -718,9 +754,9 @@ } }, "node_modules/@oxlint-tsgolint/darwin-arm64": { - "version": "0.18.1", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-0.18.1.tgz", - "integrity": "sha512-CxSd15ZwHn70UJFTXVvy76bZ9zwI097cVyjvUFmYRJwvkQF3VnrTf2oe1gomUacErksvtqLgn9OKvZhLMYwvog==", + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-arm64/-/darwin-arm64-0.22.1.tgz", + "integrity": "sha512-4150Lpgc1YM09GcjA6GSrra1JoPjC7aOpfywLjWEY4vW0Sd1qKzqHF1WRaiw0/qUZ40OATYdv3aRd7ipPkWQbw==", "cpu": [ "arm64" ], @@ -732,9 +768,9 @@ ] }, "node_modules/@oxlint-tsgolint/darwin-x64": { - "version": "0.18.1", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-0.18.1.tgz", - "integrity": "sha512-LE7VW/T/VcKhl3Z1ev5BusrxdlQ3DWweSeOB+qpBeur2h8+vCWq+M7tCO29C7lveBDfx1+rNwj4aiUVlA+Qs+g==", + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/darwin-x64/-/darwin-x64-0.22.1.tgz", + "integrity": "sha512-vFWcPWYOgZs4HWcgS1EjUZg33NLcNfEYU49KGImmCfZWkflENrmBYV4HN/C0YeAPum6ZZ/goPSvQrB/cOD+NfA==", "cpu": [ "x64" ], @@ -746,9 +782,9 @@ ] }, "node_modules/@oxlint-tsgolint/linux-arm64": { - "version": "0.18.1", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-0.18.1.tgz", - "integrity": "sha512-2AG8YIXVJJbnM0rcsJmzzWOjZXBu5REwowgUpbHZueF7OYM3wR7Xu8pXEpAojEHAtYYZ3X4rpPoetomkJx7kCw==", + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-arm64/-/linux-arm64-0.22.1.tgz", + "integrity": "sha512-6LiUpP0Zir3+29FvBm7Y28q/dBjSHqTZ5MhG1Ckw4fGhI4cAvbcwXaKvbjx1TP7rRmBNOoq/M5xdpHjTb+GAew==", "cpu": [ "arm64" ], @@ -760,9 +796,9 @@ ] }, "node_modules/@oxlint-tsgolint/linux-x64": { - "version": "0.18.1", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-0.18.1.tgz", - "integrity": "sha512-f8vDYPEdiwpA2JaDEkadTXfuqIgweQ8zcL4SX75EN2kkW2oAynjN7cd8m86uXDgB0JrcyOywbRtwnXdiIzXn2A==", + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/linux-x64/-/linux-x64-0.22.1.tgz", + "integrity": "sha512-fuX1hEQfpHauUbXADsfqVhRzrUrGabzGXbj5wsp2vKhV5uk/Rze8Mba9GdjFGECzvXudMGqHqxB4r6jGRdhxVA==", "cpu": [ "x64" ], @@ -774,9 +810,9 @@ ] }, "node_modules/@oxlint-tsgolint/win32-arm64": { - "version": "0.18.1", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-arm64/-/win32-arm64-0.18.1.tgz", - "integrity": "sha512-fBdML05KMDAL9ebWeoHIzkyI86Eq6r9YH5UDRuXJ9vAIo1EnKo0ti7hLUxNdc2dy2FF/T4k98p5wkkXvLyXqfA==", + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-arm64/-/win32-arm64-0.22.1.tgz", + "integrity": "sha512-8SZidAj+jrbZf9ZjBEYW0tiNZ+KasqB2zgW26qdiPpQSF/DzURnPmXz651IeA9YsmbVdHGIooEHUmev6QJdquA==", "cpu": [ "arm64" ], @@ -788,9 +824,9 @@ ] }, "node_modules/@oxlint-tsgolint/win32-x64": { - "version": "0.18.1", - "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-0.18.1.tgz", - "integrity": "sha512-cYZMhNrsq9ZZ3OUWHyawqiS+c8HfieYG0zuZP2LbEuWWPfdZM/22iAlo608J+27G1s9RXQhvgX6VekwWbXbD7A==", + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/@oxlint-tsgolint/win32-x64/-/win32-x64-0.22.1.tgz", + "integrity": "sha512-QweSk9H5lFh5Y+WUf2Kq/OAN88V6+62ZwGhP38gqdRotI90luXSMkruFTj7Q2rYrzH4ZVNaSqx7NY8JpSfIzqg==", "cpu": [ "x64" ], @@ -802,9 +838,9 @@ ] }, "node_modules/@oxlint/binding-android-arm-eabi": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.57.0.tgz", - "integrity": "sha512-C7EiyfAJG4B70496eV543nKiq5cH0o/xIh/ufbjQz3SIvHhlDDsyn+mRFh+aW8KskTyUpyH2LGWL8p2oN6bl1A==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.63.0.tgz", + "integrity": "sha512-A9xLtQt7i0OA1PoB/meog6kikXI9CdwEp7ZwQqmgnpKn3G3b1orvTDy8CQ6T7w1HvDrgWGB78PkFKcWgibcTCg==", "cpu": [ "arm" ], @@ -819,9 +855,9 @@ } }, "node_modules/@oxlint/binding-android-arm64": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.57.0.tgz", - "integrity": "sha512-9i80AresjZ/FZf5xK8tKFbhQnijD4s1eOZw6/FHUwD59HEZbVLRc2C88ADYJfLZrF5XofWDiRX/Ja9KefCLy7w==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.63.0.tgz", + "integrity": "sha512-SQo+ZMvdR9l3CxZp5W5gFNxSiDxclY6lOzzNpKYLF8asESpm3Pwumx0gER5T7aHLF1/2BAAtLD3DiDkdgy4V1A==", "cpu": [ "arm64" ], @@ -836,9 +872,9 @@ } }, "node_modules/@oxlint/binding-darwin-arm64": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.57.0.tgz", - "integrity": "sha512-0eUfhRz5L2yKa9I8k3qpyl37XK3oBS5BvrgdVIx599WZK63P8sMbg+0s4IuxmIiZuBK68Ek+Z+gcKgeYf0otsg==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.63.0.tgz", + "integrity": "sha512-6W82XjJDTmMnjg30427l0dufpnyLoq7wEukKdM6/g2VIybRVuQiBVh43EA4b+UxZ3+tLcKm+Or/pXGNgLCEU8g==", "cpu": [ "arm64" ], @@ -853,9 +889,9 @@ } }, "node_modules/@oxlint/binding-darwin-x64": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.57.0.tgz", - "integrity": "sha512-UvrSuzBaYOue+QMAcuDITe0k/Vhj6KZGjfnI6x+NkxBTke/VoM7ZisaxgNY0LWuBkTnd1OmeQfEQdQ48fRjkQg==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.63.0.tgz", + "integrity": "sha512-CnWd/YCuVG5W1BYkjJEVbJG11o526O9qAwBEQM+nh8K19CRFUkFdROXCyYkGmroHEYQe4vgQ6+lh3550Lp35Xw==", "cpu": [ "x64" ], @@ -870,9 +906,9 @@ } }, "node_modules/@oxlint/binding-freebsd-x64": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.57.0.tgz", - "integrity": "sha512-wtQq0dCoiw4bUwlsNVDJJ3pxJA218fOezpgtLKrbQqUtQJcM9yP8z+I9fu14aHg0uyAxIY+99toL6uBa2r7nxA==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.63.0.tgz", + "integrity": "sha512-a4eZAqrmtajqcxfdAzC+l7g3PaE3V8hpAYqqeD3fTxLXOMFdK3eNTZrU80n4dDEVm0JXy1aL5PqvqWldBl6zYA==", "cpu": [ "x64" ], @@ -887,9 +923,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-gnueabihf": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.57.0.tgz", - "integrity": "sha512-qxFWl2BBBFcT4djKa+OtMdnLgoHEJXpqjyGwz8OhW35ImoCwR5qtAGqApNYce5260FQqoAHW8S8eZTjiX67Tsg==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.63.0.tgz", + "integrity": "sha512-tYUtU9TdbU3uXF5D62g5zXJ13iniFGhXQx5vp9cyEjGdbSAY3VdFBSaldYvyoDmgMZ0ZYuwQP1Y4t2Fhejwa0w==", "cpu": [ "arm" ], @@ -904,9 +940,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-musleabihf": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.57.0.tgz", - "integrity": "sha512-SQoIsBU7J0bDW15/f0/RvxHfY3Y0+eB/caKBQtNFbuerTiA6JCYx9P1MrrFTwY2dTm/lMgTSgskvCEYk2AtG/Q==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.63.0.tgz", + "integrity": "sha512-I5r3twFf776UZg9dmRo2xbrKt00tTkORXEVe0ctg4vdTkQvJAjiCHxnbAU2HL1AiJ9cqADA76MAliuilsAWnvg==", "cpu": [ "arm" ], @@ -921,9 +957,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-gnu": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.57.0.tgz", - "integrity": "sha512-jqxYd1W6WMeozsCmqe9Rzbu3SRrGTyGDAipRlRggetyYbUksJqJKvUNTQtZR/KFoJPb+grnSm5SHhdWrywv3RQ==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.63.0.tgz", + "integrity": "sha512-t7ltUkg6FFh4b564QyGir8xIj/QZbXu8FlcRkcyW9+ztr/mfRHlvUOFd95pJCXi9s/L5DrUeWWgpXRS+V+6igQ==", "cpu": [ "arm64" ], @@ -938,9 +974,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-musl": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.57.0.tgz", - "integrity": "sha512-i66WyEPVEvq9bxRUCJ/MP5EBfnTDN3nhwEdFZFTO5MmLLvzngfWEG3NSdXQzTT3vk5B9i6C2XSIYBh+aG6uqyg==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.63.0.tgz", + "integrity": "sha512-Q5mmZy/XWjuYFUuQyYjOvZ5U/JkKEwnpir6hGxhh6HcdP0V/BKxLo8dqkfF/t7r7AguB17dfS/8+go5AQDRR6g==", "cpu": [ "arm64" ], @@ -955,9 +991,9 @@ } }, "node_modules/@oxlint/binding-linux-ppc64-gnu": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.57.0.tgz", - "integrity": "sha512-oMZDCwz4NobclZU3pH+V1/upVlJZiZvne4jQP+zhJwt+lmio4XXr4qG47CehvrW1Lx2YZiIHuxM2D4YpkG3KVA==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.63.0.tgz", + "integrity": "sha512-uBGtuZ0TzLB4x5wVa82HGNvYqY8buwDhyCnCP0R0gkk9szqVsP0MeTtD5HX7EsEuFIt+aYmYxuxeVxs3nTSwtQ==", "cpu": [ "ppc64" ], @@ -972,9 +1008,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-gnu": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.57.0.tgz", - "integrity": "sha512-uoBnjJ3MMEBbfnWC1jSFr7/nSCkcQYa72NYoNtLl1imshDnWSolYCjzb8LVCwYCCfLJXD+0gBLD7fyC14c0+0g==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.63.0.tgz", + "integrity": "sha512-h4s6FwxE+9MeA181o0dnDwHP32Y/bG8EiB/vrD6Ib+AMt6haigDc/0bUtI/sLmQDBMJnUfaCmtSSrEAqjtEVrA==", "cpu": [ "riscv64" ], @@ -989,9 +1025,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-musl": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.57.0.tgz", - "integrity": "sha512-BdrwD7haPZ8a9KrZhKJRSj6jwCor+Z8tHFZ3PT89Y3Jq5v3LfMfEePeAmD0LOTWpiTmzSzdmyw9ijneapiVHKQ==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.63.0.tgz", + "integrity": "sha512-2EaNcCBR8Mcjl5ARtuN3BdEpVkX7KpjSjMGZ/mJMIeaXgTtdz5ytg2VwygMSStA/k0ixfvZFoZOfjDEcouV5vQ==", "cpu": [ "riscv64" ], @@ -1006,9 +1042,9 @@ } }, "node_modules/@oxlint/binding-linux-s390x-gnu": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.57.0.tgz", - "integrity": "sha512-BNs+7ZNsRstVg2tpNxAXfMX/Iv5oZh204dVyb8Z37+/gCh+yZqNTlg6YwCLIMPSk5wLWIGOaQjT0GUOahKYImw==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.63.0.tgz", + "integrity": "sha512-p4hlf/fd7TrYYl3QrWWD0GocqJefwMu3cHQhmi2FvEB/YOvFb5DZN3SMBaPi7B1TM5DeypkEtrVib674q1KKPg==", "cpu": [ "s390x" ], @@ -1023,9 +1059,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-gnu": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.57.0.tgz", - "integrity": "sha512-AghS18w+XcENcAX0+BQGLiqjpqpaxKJa4cWWP0OWNLacs27vHBxu7TYkv9LUSGe5w8lOJHeMxcYfZNOAPqw2bg==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.63.0.tgz", + "integrity": "sha512-Vgq9rkRVcPcjbcH+ihYTfpeR7vCXfqpd+z5ItTGc0yYUV59L5ceHYN1iV4H9bKGV7Rn5hkVc7x3mSvHegduENA==", "cpu": [ "x64" ], @@ -1040,9 +1076,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-musl": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.57.0.tgz", - "integrity": "sha512-E/FV3GB8phu/Rpkhz5T96hAiJlGzn91qX5yj5gU754P5cmVGXY1Jw/VSjDSlZBCY3VHjsVLdzgdkJaomEmcNOg==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.63.0.tgz", + "integrity": "sha512-3/Lkq/ncooA61rorrC+ZQed1Bc4VpGj+WnGsp58zmxKgvZ2vhreu+dcVyr3mX8NUpq7mfZ4gDDTou/yrF1Pd7A==", "cpu": [ "x64" ], @@ -1057,9 +1093,9 @@ } }, "node_modules/@oxlint/binding-openharmony-arm64": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.57.0.tgz", - "integrity": "sha512-xvZ2yZt0nUVfU14iuGv3V25jpr9pov5N0Wr28RXnHFxHCRxNDMtYPHV61gGLhN9IlXM96gI4pyYpLSJC5ClLCQ==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.63.0.tgz", + "integrity": "sha512-0/EdD/6hDkx5Mfd769PTjvEM8mZ/6Dfukp1dBCL/2PjlIVGEtYdNZyok6ChqYPsT9JcFnlQnUeQzO0/1L/oC9w==", "cpu": [ "arm64" ], @@ -1074,9 +1110,9 @@ } }, "node_modules/@oxlint/binding-win32-arm64-msvc": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.57.0.tgz", - "integrity": "sha512-Z4D8Pd0AyHBKeazhdIXeUUy5sIS3Mo0veOlzlDECg6PhRRKgEsBJCCV1n+keUZtQ04OP+i7+itS3kOykUyNhDg==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.63.0.tgz", + "integrity": "sha512-wb0CUkN8ngwPiRQBjD1Cj0LsHeNvm+Xt6YBHDMtj2DVQVD6Oj8Ri7g6BD+KICf6LaBqZlmzOvy6nF9E/8yyGOg==", "cpu": [ "arm64" ], @@ -1091,9 +1127,9 @@ } }, "node_modules/@oxlint/binding-win32-ia32-msvc": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.57.0.tgz", - "integrity": "sha512-StOZ9nFMVKvevicbQfql6Pouu9pgbeQnu60Fvhz2S6yfMaii+wnueLnqQ5I1JPgNF0Syew4voBlAaHD13wH6tw==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.63.0.tgz", + "integrity": "sha512-BX5iq+ovdNlVYhSn5qPMUIT0uwAwt2lmEnCnzK+Gkhw4DovIvhGb96OFhV8yzQNUnQxn/xGkOR+X+BLrLDNm8w==", "cpu": [ "ia32" ], @@ -1108,9 +1144,9 @@ } }, "node_modules/@oxlint/binding-win32-x64-msvc": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.57.0.tgz", - "integrity": "sha512-6PuxhYgth8TuW0+ABPOIkGdBYw+qYGxgIdXPHSVpiCDm+hqTTWCmC739St1Xni0DJBt8HnSHTG67i1y6gr8qrA==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.63.0.tgz", + "integrity": "sha512-QeN/WELOfsXMeYwxvfgQrl6CbVftYUCZsGXHjXQd5Trccm8+i4gmtxaOui4xbJQaiDlviF8F3yLSBloQUeFsfA==", "cpu": [ "x64" ], @@ -1125,13 +1161,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", - "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.57.0" + "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" @@ -1141,16 +1177,13 @@ } }, "node_modules/@react-aria/focus": { - "version": "3.21.2", - "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.21.2.tgz", - "integrity": "sha512-JWaCR7wJVggj+ldmM/cb/DXFg47CXR55lznJhZBh4XVqJjMKwaOOqpT5vNN7kpC1wUpXicGNuDnJDN1S/+6dhQ==", + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.22.0.tgz", + "integrity": "sha512-ZfDOVuVhqDsM9mkNji3QUZ/d40JhlVgXrDkrfXylM1035QCrcTHN7m2DpbE95sU2A8EQb4wikvt5jM6K/73BPg==", "license": "Apache-2.0", "dependencies": { - "@react-aria/interactions": "^3.25.6", - "@react-aria/utils": "^3.31.0", - "@react-types/shared": "^3.32.1", "@swc/helpers": "^0.5.0", - "clsx": "^2.0.0" + "react-aria": "3.48.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", @@ -1158,89 +1191,33 @@ } }, "node_modules/@react-aria/interactions": { - "version": "3.25.6", - "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.6.tgz", - "integrity": "sha512-5UgwZmohpixwNMVkMvn9K1ceJe6TzlRlAfuYoQDUuOkk62/JVJNDLAPKIf5YMRc7d2B0rmfgaZLMtbREb0Zvkw==", - "license": "Apache-2.0", - "dependencies": { - "@react-aria/ssr": "^3.9.10", - "@react-aria/utils": "^3.31.0", - "@react-stately/flags": "^3.1.2", - "@react-types/shared": "^3.32.1", - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", - "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/ssr": { - "version": "3.9.10", - "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.10.tgz", - "integrity": "sha512-hvTm77Pf+pMBhuBm760Li0BVIO38jv1IBws1xFm1NoL26PU+fe+FMW5+VZWyANR6nYL65joaJKZqOdTQMkO9IQ==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0" - }, - "engines": { - "node": ">= 12" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, - "node_modules/@react-aria/utils": { - "version": "3.31.0", - "resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.31.0.tgz", - "integrity": "sha512-ABOzCsZrWzf78ysswmguJbx3McQUja7yeGj6/vZo4JVsZNlxAN+E9rs381ExBRI0KzVo6iBTeX5De8eMZPJXig==", + "version": "3.28.0", + "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.28.0.tgz", + "integrity": "sha512-OXwdU1EWFdMxmr/K1CXNGJzmNlCClByb+PuCaqUyzBymHPCGVhawirLIon/CrIN5psh3AiWpHSh4H0WeJdVpng==", "license": "Apache-2.0", "dependencies": { - "@react-aria/ssr": "^3.9.10", - "@react-stately/flags": "^3.1.2", - "@react-stately/utils": "^3.10.8", - "@react-types/shared": "^3.32.1", + "@react-types/shared": "^3.34.0", "@swc/helpers": "^0.5.0", - "clsx": "^2.0.0" + "react-aria": "3.48.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@react-stately/flags": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz", - "integrity": "sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0" - } - }, - "node_modules/@react-stately/utils": { - "version": "3.10.8", - "resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.8.tgz", - "integrity": "sha512-SN3/h7SzRsusVQjQ4v10LaVsDc81jyyR0DD5HnsQitm/I5WDpaSr2nRHtyloPFU48jlql1XX/S04T2DLQM7Y3g==", - "license": "Apache-2.0", - "dependencies": { - "@swc/helpers": "^0.5.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" - } - }, "node_modules/@react-types/shared": { - "version": "3.32.1", - "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.32.1.tgz", - "integrity": "sha512-famxyD5emrGGpFuUlgOP6fVW2h/ZaF405G5KDi3zPHzyjAWys/8W6NAVJtNbkCkhedmvL0xOhvt8feGXyXaw5w==", + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.34.0.tgz", + "integrity": "sha512-gp6xo/s2lX54AlTjOiqwDnxA7UW79BNvI9dB9pr3LZTzRKCd1ZA+ZbgKw/ReIiWuvvVw/8QFJpnqeeFyLocMcQ==", "license": "Apache-2.0", "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "node_modules/@reduxjs/toolkit": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.1.tgz", - "integrity": "sha512-HjhlEREguAyBTGNzRlGNiDHGQ2EjLSPWwdhhpoEqHYy8hWak3Dp6/fU72OfqVsiMb8S6rbfPsWUF24fxpilrVA==", + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -1264,9 +1241,9 @@ } }, "node_modules/@reduxjs/toolkit/node_modules/immer": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.0.1.tgz", - "integrity": "sha512-naDCyggtcBWANtIrjQEajhhBEuL9b0Zg4zmlWK2CzS6xCWSE39/vvf4LqnMjUAWHBhot4m9MHCM/Z+mfWhUkiA==", + "version": "11.1.6", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.6.tgz", + "integrity": "sha512-uwrF08UBQfxk49i9WcUeCx045wjB1zXEHNJmbYHPVVspxmjwSeWCoKbB8DEIvs3XkBJV6lcRAyLaWJ2+u3MMCw==", "license": "MIT", "funding": { "type": "opencollective", @@ -1274,9 +1251,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", "cpu": [ "arm64" ], @@ -1290,9 +1267,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", "cpu": [ "arm64" ], @@ -1306,9 +1283,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", "cpu": [ "x64" ], @@ -1322,9 +1299,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", "cpu": [ "x64" ], @@ -1338,9 +1315,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", - "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", "cpu": [ "arm" ], @@ -1354,9 +1331,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", "cpu": [ "arm64" ], @@ -1370,9 +1347,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", "cpu": [ "arm64" ], @@ -1386,9 +1363,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", "cpu": [ "ppc64" ], @@ -1402,9 +1379,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", "cpu": [ "s390x" ], @@ -1418,9 +1395,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", "cpu": [ "x64" ], @@ -1434,9 +1411,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", "cpu": [ "x64" ], @@ -1450,9 +1427,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", "cpu": [ "arm64" ], @@ -1466,25 +1443,27 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", - "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", "cpu": [ "wasm32" ], "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", "cpu": [ "arm64" ], @@ -1498,9 +1477,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", "cpu": [ "x64" ], @@ -1538,9 +1517,9 @@ } }, "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, "node_modules/@standard-schema/utils": { @@ -1550,15 +1529,15 @@ "license": "MIT" }, "node_modules/@swc/core": { - "version": "1.15.21", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.21.tgz", - "integrity": "sha512-fkk7NJcBscrR3/F8jiqlMptRHP650NxqDnspBMrRe5d8xOoCy9MLL5kOBLFXjFLfMo3KQQHhk+/jUULOMlR1uQ==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.33.tgz", + "integrity": "sha512-jOlwnFV2xhuuZeAUILGFULeR6vDPfijEJ57evfocwznQldLU3w2cZ9bSDryY9ip+AsM3r1NJKzf47V2NXebkeQ==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.25" + "@swc/types": "^0.1.26" }, "engines": { "node": ">=10" @@ -1568,18 +1547,18 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.15.21", - "@swc/core-darwin-x64": "1.15.21", - "@swc/core-linux-arm-gnueabihf": "1.15.21", - "@swc/core-linux-arm64-gnu": "1.15.21", - "@swc/core-linux-arm64-musl": "1.15.21", - "@swc/core-linux-ppc64-gnu": "1.15.21", - "@swc/core-linux-s390x-gnu": "1.15.21", - "@swc/core-linux-x64-gnu": "1.15.21", - "@swc/core-linux-x64-musl": "1.15.21", - "@swc/core-win32-arm64-msvc": "1.15.21", - "@swc/core-win32-ia32-msvc": "1.15.21", - "@swc/core-win32-x64-msvc": "1.15.21" + "@swc/core-darwin-arm64": "1.15.33", + "@swc/core-darwin-x64": "1.15.33", + "@swc/core-linux-arm-gnueabihf": "1.15.33", + "@swc/core-linux-arm64-gnu": "1.15.33", + "@swc/core-linux-arm64-musl": "1.15.33", + "@swc/core-linux-ppc64-gnu": "1.15.33", + "@swc/core-linux-s390x-gnu": "1.15.33", + "@swc/core-linux-x64-gnu": "1.15.33", + "@swc/core-linux-x64-musl": "1.15.33", + "@swc/core-win32-arm64-msvc": "1.15.33", + "@swc/core-win32-ia32-msvc": "1.15.33", + "@swc/core-win32-x64-msvc": "1.15.33" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" @@ -1591,9 +1570,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.15.21", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.21.tgz", - "integrity": "sha512-SA8SFg9dp0qKRH8goWsax6bptFE2EdmPf2YRAQW9WoHGf3XKM1bX0nd5UdwxmC5hXsBUZAYf7xSciCler6/oyA==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.33.tgz", + "integrity": "sha512-N+L0uXhuO7FIfzqwgxmzv0zIpV0qEp8wPX3QQs2p4atjMoywup2JTeDlXPw+z9pWJGCae3JjM+tZ6myclI+2gA==", "cpu": [ "arm64" ], @@ -1608,9 +1587,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.15.21", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.21.tgz", - "integrity": "sha512-//fOVntgowz9+V90lVsNCtyyrtbHp3jWH6Rch7MXHXbcvbLmbCTmssl5DeedUWLLGiAAW1wksBdqdGYOTjaNLw==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.33.tgz", + "integrity": "sha512-/Il4QHSOhV4FekbsDtkrNmKbsX26oSysvgrRswa/RYOHXAkwXDbB4jaeKq6PsJLSPkzJ2KzQ061gtBnk0vNHfA==", "cpu": [ "x64" ], @@ -1625,9 +1604,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.15.21", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.21.tgz", - "integrity": "sha512-meNI4Sh6h9h8DvIfEc0l5URabYMSuNvyisLmG6vnoYAS43s8ON3NJR8sDHvdP7NJTrLe0q/x2XCn6yL/BeHcZg==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.33.tgz", + "integrity": "sha512-C64hBnBxq4viOPQ8hlx+2lJ23bzZBGnjw7ryALmS+0Q3zHmwO8lw1/DArLENw4Q18/0w5wdEO1k3m1wWNtKGqQ==", "cpu": [ "arm" ], @@ -1642,9 +1621,9 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.15.21", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.21.tgz", - "integrity": "sha512-QrXlNQnHeXqU2EzLlnsPoWEh8/GtNJLvfMiPsDhk+ht6Xv8+vhvZ5YZ/BokNWSIZiWPKLAqR0M7T92YF5tmD3g==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.33.tgz", + "integrity": "sha512-TRJfnJbX3jqpxRDRoieMzRiCBS5jOmXNb3iQXmcgjFEHKLnAgK1RZRU8Cq1MsPqO4jAJp/ld1G4O3fXuxv85uw==", "cpu": [ "arm64" ], @@ -1659,9 +1638,9 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.15.21", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.21.tgz", - "integrity": "sha512-8/yGCMO333ultDaMQivE5CjO6oXDPeeg1IV4sphojPkb0Pv0i6zvcRIkgp60xDB+UxLr6VgHgt+BBgqS959E9g==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.33.tgz", + "integrity": "sha512-il7tYM+CpUNzieQbwAjFT1P8zqAhmGWNAGhQZBnxurXZ0aNn+5nqYFTEUKNZl7QibtT0uQXzTZrNGHCIj6Y1Og==", "cpu": [ "arm64" ], @@ -1676,9 +1655,9 @@ } }, "node_modules/@swc/core-linux-ppc64-gnu": { - "version": "1.15.21", - "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.21.tgz", - "integrity": "sha512-ucW0HzPx0s1dgRvcvuLSPSA/2Kk/VYTv9st8qe1Kc22Gu0Q0rH9+6TcBTmMuNIp0Xs4BPr1uBttmbO1wEGI49Q==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.33.tgz", + "integrity": "sha512-ZtNBwN0Z7CFj9Il0FcPaKdjgP7URyKu/3RfH46vq+0paOBqLj4NYldD6Qo//Duif/7IOtAraUfDOmp0PLAufog==", "cpu": [ "ppc64" ], @@ -1693,9 +1672,9 @@ } }, "node_modules/@swc/core-linux-s390x-gnu": { - "version": "1.15.21", - "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.21.tgz", - "integrity": "sha512-ulTnOGc5I7YRObE/9NreAhQg94QkiR5qNhhcUZ1iFAYjzg/JGAi1ch+s/Ixe61pMIr8bfVrF0NOaB0f8wjaAfA==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.33.tgz", + "integrity": "sha512-De1IyajoOmhOYYjw/lx66bKlyDpHZTueqwpDrWgf5O7T6d1ODeJJO9/OqMBmrBQc5C+dNnlmIufHsp4QVCWufA==", "cpu": [ "s390x" ], @@ -1710,9 +1689,9 @@ } }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.15.21", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.21.tgz", - "integrity": "sha512-D0RokxtM+cPvSqJIKR6uja4hbD+scI9ezo95mBhfSyLUs9wnPPl26sLp1ZPR/EXRdYm3F3S6RUtVi+8QXhT24Q==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.33.tgz", + "integrity": "sha512-mGTH0YxmUN+x6vRN/I6NOk5X0ogNktkwPnJ94IMvR7QjhRDwL0O8RXEDhyUM0YtwWrryBOqaJQBX4zruxEPRGw==", "cpu": [ "x64" ], @@ -1727,9 +1706,9 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.15.21", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.21.tgz", - "integrity": "sha512-nER8u7VeRfmU6fMDzl1NQAbbB/G7O2avmvCOwIul1uGkZ2/acbPH+DCL9h5+0yd/coNcxMBTL6NGepIew+7C2w==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.33.tgz", + "integrity": "sha512-hj628ZkSEJf6zMf5VMbYrG2O6QqyTIp2qwY6VlCjvIa9lAEZ5c2lfPblCLVGYubTeLJDxadLB/CxqQYOQABeEQ==", "cpu": [ "x64" ], @@ -1744,9 +1723,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.15.21", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.21.tgz", - "integrity": "sha512-+/AgNBnjYugUA8C0Do4YzymgvnGbztv7j8HKSQLvR/DQgZPoXQ2B3PqB2mTtGh/X5DhlJWiqnunN35JUgWcAeQ==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.33.tgz", + "integrity": "sha512-GV2oohtN2/5+KSccl86VULu3aT+LrISC8uzgSq0FRnikpD+Zwc+sBlXmoKQ+Db6jI57ITUOIB8jRkdGMABC29g==", "cpu": [ "arm64" ], @@ -1761,9 +1740,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.15.21", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.21.tgz", - "integrity": "sha512-IkSZj8PX/N4HcaFhMQtzmkV8YSnuNoJ0E6OvMwFiOfejPhiKXvl7CdDsn1f4/emYEIDO3fpgZW9DTaCRMDxaDA==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.33.tgz", + "integrity": "sha512-gtyvzSNR8DHKfFEA2uqb8Ld1myqi6uEg2jyeUq3ikn5ytYs7H8RpZYC8mdy4NXr8hfcdJfCLXPlYaqqfBXpoEQ==", "cpu": [ "ia32" ], @@ -1778,9 +1757,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.15.21", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.21.tgz", - "integrity": "sha512-zUyWso7OOENB6e1N1hNuNn8vbvLsTdKQ5WKLgt/JcBNfJhKy/6jmBmqI3GXk/MyvQKd5SLvP7A0F36p7TeDqvw==", + "version": "1.15.33", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.33.tgz", + "integrity": "sha512-d6fRqQSkJI+kmMEBWaDQ7TMl8+YjLYbwRUPZQ9DY0ORBJeTzOrG0twvfvlZ2xgw6jA0ScQKgfBm4vHLSLl5Hqg==", "cpu": [ "x64" ], @@ -1802,9 +1781,9 @@ "license": "Apache-2.0" }, "node_modules/@swc/helpers": { - "version": "0.5.17", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", - "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", + "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.8.0" @@ -1821,9 +1800,9 @@ } }, "node_modules/@tailwindcss/forms": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz", - "integrity": "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==", + "version": "0.5.11", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz", + "integrity": "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==", "dev": true, "license": "MIT", "dependencies": { @@ -1834,49 +1813,49 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", - "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", + "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "enhanced-resolve": "^5.18.3", + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", - "lightningcss": "1.30.2", + "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.1.18" + "tailwindcss": "4.2.4" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", - "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", + "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", "dev": true, "license": "MIT", "engines": { - "node": ">= 10" + "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.18", - "@tailwindcss/oxide-darwin-arm64": "4.1.18", - "@tailwindcss/oxide-darwin-x64": "4.1.18", - "@tailwindcss/oxide-freebsd-x64": "4.1.18", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", - "@tailwindcss/oxide-linux-x64-musl": "4.1.18", - "@tailwindcss/oxide-wasm32-wasi": "4.1.18", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + "@tailwindcss/oxide-android-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-x64": "4.2.4", + "@tailwindcss/oxide-freebsd-x64": "4.2.4", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-x64-musl": "4.2.4", + "@tailwindcss/oxide-wasm32-wasi": "4.2.4", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", - "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", + "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", "cpu": [ "arm64" ], @@ -1887,13 +1866,13 @@ "android" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", - "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", + "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", "cpu": [ "arm64" ], @@ -1904,13 +1883,13 @@ "darwin" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", - "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", + "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", "cpu": [ "x64" ], @@ -1921,13 +1900,13 @@ "darwin" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", - "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", + "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", "cpu": [ "x64" ], @@ -1938,13 +1917,13 @@ "freebsd" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", - "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", + "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", "cpu": [ "arm" ], @@ -1955,13 +1934,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", - "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", + "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", "cpu": [ "arm64" ], @@ -1972,13 +1951,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", - "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", + "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", "cpu": [ "arm64" ], @@ -1989,13 +1968,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", - "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", + "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", "cpu": [ "x64" ], @@ -2006,13 +1985,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", - "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", + "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", "cpu": [ "x64" ], @@ -2023,13 +2002,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", - "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", + "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -2045,21 +2024,21 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.4.0" + "tslib": "^2.8.1" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", - "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", + "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", "cpu": [ "arm64" ], @@ -2070,13 +2049,13 @@ "win32" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", - "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", + "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", "cpu": [ "x64" ], @@ -2087,21 +2066,21 @@ "win32" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/postcss": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz", - "integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.4.tgz", + "integrity": "sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==", "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.1.18", - "@tailwindcss/oxide": "4.1.18", - "postcss": "^8.4.41", - "tailwindcss": "4.1.18" + "@tailwindcss/node": "4.2.4", + "@tailwindcss/oxide": "4.2.4", + "postcss": "^8.5.6", + "tailwindcss": "4.2.4" } }, "node_modules/@tailwindcss/typography": { @@ -2118,668 +2097,143 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", - "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz", + "integrity": "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.2.2", - "@tailwindcss/oxide": "4.2.2", - "tailwindcss": "4.2.2" + "@tailwindcss/node": "4.2.4", + "@tailwindcss/oxide": "4.2.4", + "tailwindcss": "4.2.4" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, - "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/node": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", - "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", - "dev": true, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.24", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz", + "integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==", "license": "MIT", "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", - "jiti": "^2.6.1", - "lightningcss": "1.32.0", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.2.2" + "@tanstack/virtual-core": "3.14.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", - "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.2", - "@tailwindcss/oxide-darwin-arm64": "4.2.2", - "@tailwindcss/oxide-darwin-x64": "4.2.2", - "@tailwindcss/oxide-freebsd-x64": "4.2.2", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", - "@tailwindcss/oxide-linux-x64-musl": "4.2.2", - "@tailwindcss/oxide-wasm32-wasi": "4.2.2", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" - } - }, - "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", - "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/@tanstack/virtual-core": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz", + "integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==", "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 20" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", - "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "license": "MIT", "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", - "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" + "dependencies": { + "@types/d3-color": "*" } }, - "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", - "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 20" + "dependencies": { + "@types/d3-time": "*" } }, - "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", - "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", - "cpu": [ - "arm" - ], - "dev": true, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" + "dependencies": { + "@types/d3-path": "*" } }, - "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", - "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "devOptional": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" + "dependencies": { + "undici-types": "~7.19.0" } }, - "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", - "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" + "dependencies": { + "csstype": "^3.2.2" } }, - "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", - "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", - "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", - "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.8.1", - "@emnapi/runtime": "^1.8.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.1", - "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", - "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/vite/node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", - "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/vite/node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/@tailwindcss/vite/node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@tailwindcss/vite/node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@tailwindcss/vite/node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@tailwindcss/vite/node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@tailwindcss/vite/node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@tailwindcss/vite/node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@tailwindcss/vite/node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@tailwindcss/vite/node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@tailwindcss/vite/node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@tailwindcss/vite/node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@tailwindcss/vite/node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@tailwindcss/vite/node_modules/tailwindcss": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", - "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tanstack/react-virtual": { - "version": "3.13.13", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.13.tgz", - "integrity": "sha512-4o6oPMDvQv+9gMi8rE6gWmsOjtUZUYIJHv7EB+GblyYdi8U6OqLl8rhHWIUZSL1dUU2dPwTdTgybCKf9EjIrQg==", - "license": "MIT", - "dependencies": { - "@tanstack/virtual-core": "3.13.13" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/@tanstack/virtual-core": { - "version": "3.13.13", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.13.tgz", - "integrity": "sha512-uQFoSdKKf5S8k51W5t7b2qpfkyIbdHMzAn+AMQvHPxKUPeo1SsGaA4JRISQT87jm28b7z8OEqPcg1IOZagQHcA==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/d3-array": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", - "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", - "license": "MIT" - }, - "node_modules/@types/d3-color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "license": "MIT" - }, - "node_modules/@types/d3-ease": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", - "license": "MIT" - }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "license": "MIT", - "dependencies": { - "@types/d3-color": "*" - } - }, - "node_modules/@types/d3-path": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", - "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", - "license": "MIT" - }, - "node_modules/@types/d3-scale": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", - "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", - "license": "MIT", - "dependencies": { - "@types/d3-time": "*" - } - }, - "node_modules/@types/d3-shape": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", - "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", - "license": "MIT", - "dependencies": { - "@types/d3-path": "*" - } - }, - "node_modules/@types/d3-time": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", - "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", - "license": "MIT" - }, - "node_modules/@types/d3-timer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "24.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", - "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.16.0" - } - }, - "node_modules/@types/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", - "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -2889,9 +2343,9 @@ "license": "MIT" }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -2930,6 +2384,31 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/array-timsort": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", @@ -2938,9 +2417,9 @@ "license": "MIT" }, "node_modules/autoprefixer": { - "version": "10.4.23", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", - "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", "dev": true, "funding": [ { @@ -2958,8 +2437,8 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001760", + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -2975,13 +2454,16 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.9.6", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.6.tgz", - "integrity": "sha512-v9BVVpOTLB59C9E7aSnmIF8h7qRsFpx+A2nugVMTszEOMcfjlZMsXRm4LF23I3Z9AJxc8ANpIvzbzONoX9VJlg==", + "version": "2.10.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", + "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/bmp-js": { @@ -2991,9 +2473,9 @@ "license": "MIT" }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, "funding": [ { @@ -3011,11 +2493,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -3025,9 +2507,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001760", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz", - "integrity": "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==", + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", "dev": true, "funding": [ { @@ -3105,14 +2587,13 @@ } }, "node_modules/comment-json": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.5.0.tgz", - "integrity": "sha512-aKl8CwoMKxVTfAK4dFN4v54AEvuUh9pzmgVIBeK6gBomLwMgceQUKKWHzJdW1u1VQXQuwnJ7nJGWYYMTl5U4yg==", + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.6.2.tgz", + "integrity": "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==", "dev": true, "license": "MIT", "dependencies": { "array-timsort": "^1.0.3", - "core-util-is": "^1.0.3", "esprima": "^4.0.1" }, "engines": { @@ -3142,13 +2623,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, - "license": "MIT" - }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -3219,9 +2693,9 @@ } }, "node_modules/d3-format": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", - "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", "license": "ISC", "engines": { "node": ">=12" @@ -3310,9 +2784,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.19", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", - "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", "license": "MIT" }, "node_modules/decimal.js-light": { @@ -3346,9 +2820,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.267", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", - "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "version": "1.5.349", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", + "integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==", "dev": true, "license": "ISC" }, @@ -3360,14 +2834,14 @@ "license": "MIT" }, "node_modules/enhanced-resolve": { - "version": "5.20.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", - "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", + "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" + "tapable": "^2.3.3" }, "engines": { "node": ">=10.13.0" @@ -3387,9 +2861,9 @@ } }, "node_modules/es-toolkit": { - "version": "1.42.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.42.0.tgz", - "integrity": "sha512-SLHIyY7VfDJBM8clz4+T2oquwTQxEzu263AyhVK4jREOAwJ+8eebaa4wM3nlvnAqhDrMm2EsA6hWHaQsMPQ1nA==", + "version": "1.46.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", + "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", "license": "MIT", "workspaces": [ "docs", @@ -3434,9 +2908,9 @@ } }, "node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, "node_modules/fdir": { @@ -3470,22 +2944,22 @@ } }, "node_modules/focus-trap": { - "version": "7.6.6", - "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.6.tgz", - "integrity": "sha512-v/Z8bvMCajtx4mEXmOo7QEsIzlIOqRXTIwgUfsFOF9gEsespdbD0AkPIka1bSXZ8Y8oZ+2IVDQZePkTfEHZl7Q==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-8.2.0.tgz", + "integrity": "sha512-CaBdQ9P4fa/yCA6pDf/3aJd8bf9IOG5QGK21/E+86o2V4V8kzXaR4A9E6tNR7KkkS1+T5ZIU1tJDBDLwsucz9g==", "license": "MIT", "dependencies": { - "tabbable": "^6.3.0" + "tabbable": "^6.4.0" } }, "node_modules/focus-trap-react": { - "version": "11.0.4", - "resolved": "https://registry.npmjs.org/focus-trap-react/-/focus-trap-react-11.0.4.tgz", - "integrity": "sha512-tC7jC/yqeAqhe4irNIzdyDf9XCtGSeECHiBSYJBO/vIN0asizbKZCt8TarB6/XqIceu42ajQ/U4lQJ9pZlWjrg==", + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/focus-trap-react/-/focus-trap-react-12.0.1.tgz", + "integrity": "sha512-MUlm5W4YP9qXZt6J7cRkdZtVekN7WQRqh5STvcRM8s2juTQWNFIz2BYlvaEddij9h04H/9SL8QcXkvqEEWUD6A==", "license": "MIT", "dependencies": { - "focus-trap": "^7.6.5", - "tabbable": "^6.2.0" + "focus-trap": "^8.1.0", + "tabbable": "^6.4.0" }, "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", @@ -3509,13 +2983,13 @@ } }, "node_modules/framer-motion": { - "version": "12.23.26", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz", - "integrity": "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==", + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", + "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", "license": "MIT", "dependencies": { - "motion-dom": "^12.23.23", - "motion-utils": "^12.23.6", + "motion-dom": "^12.38.0", + "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { @@ -3536,9 +3010,10 @@ } }, "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -3688,20 +3163,19 @@ } }, "node_modules/kysely": { - "version": "0.27.6", - "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.27.6.tgz", - "integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==", + "version": "0.28.17", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.17.tgz", + "integrity": "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" } }, "node_modules/lightningcss": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", - "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", - "dev": true, + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -3714,27 +3188,26 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.30.2", - "lightningcss-darwin-arm64": "1.30.2", - "lightningcss-darwin-x64": "1.30.2", - "lightningcss-freebsd-x64": "1.30.2", - "lightningcss-linux-arm-gnueabihf": "1.30.2", - "lightningcss-linux-arm64-gnu": "1.30.2", - "lightningcss-linux-arm64-musl": "1.30.2", - "lightningcss-linux-x64-gnu": "1.30.2", - "lightningcss-linux-x64-musl": "1.30.2", - "lightningcss-win32-arm64-msvc": "1.30.2", - "lightningcss-win32-x64-msvc": "1.30.2" + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", - "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3749,13 +3222,12 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", - "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3770,13 +3242,12 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", - "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3791,13 +3262,12 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", - "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3812,13 +3282,12 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", - "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3833,13 +3302,12 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", - "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3854,13 +3322,12 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", - "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3875,13 +3342,12 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", - "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3896,13 +3362,12 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", - "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3917,13 +3382,12 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", - "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3938,13 +3402,12 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", - "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -4036,19 +3499,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/log-update/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/log-update/node_modules/slice-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", @@ -4099,24 +3549,24 @@ } }, "node_modules/motion-dom": { - "version": "12.23.23", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz", - "integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==", + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", + "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", "license": "MIT", "dependencies": { - "motion-utils": "^12.23.6" + "motion-utils": "^12.36.0" } }, "node_modules/motion-utils": { - "version": "12.23.6", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", - "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", + "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "funding": [ { "type": "github", @@ -4152,9 +3602,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", "dev": true, "license": "MIT" }, @@ -4184,9 +3634,9 @@ } }, "node_modules/oxfmt": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.42.0.tgz", - "integrity": "sha512-QhejGErLSMReNuZ6vxgFHDyGoPbjTRNi6uGHjy0cvIjOQFqD6xmr/T+3L41ixR3NIgzcNiJ6ylQKpvShTgDfqg==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.48.0.tgz", + "integrity": "sha512-AVaLh+7XeGx+R1zfFV+f6VV61nT2MWVJXVUDhbTm5LBWGyNt64xAyh3NYYyjeY2WykNt9AvqSQLPHcbWquYF9g==", "dev": true, "license": "MIT", "dependencies": { @@ -4202,31 +3652,31 @@ "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxfmt/binding-android-arm-eabi": "0.42.0", - "@oxfmt/binding-android-arm64": "0.42.0", - "@oxfmt/binding-darwin-arm64": "0.42.0", - "@oxfmt/binding-darwin-x64": "0.42.0", - "@oxfmt/binding-freebsd-x64": "0.42.0", - "@oxfmt/binding-linux-arm-gnueabihf": "0.42.0", - "@oxfmt/binding-linux-arm-musleabihf": "0.42.0", - "@oxfmt/binding-linux-arm64-gnu": "0.42.0", - "@oxfmt/binding-linux-arm64-musl": "0.42.0", - "@oxfmt/binding-linux-ppc64-gnu": "0.42.0", - "@oxfmt/binding-linux-riscv64-gnu": "0.42.0", - "@oxfmt/binding-linux-riscv64-musl": "0.42.0", - "@oxfmt/binding-linux-s390x-gnu": "0.42.0", - "@oxfmt/binding-linux-x64-gnu": "0.42.0", - "@oxfmt/binding-linux-x64-musl": "0.42.0", - "@oxfmt/binding-openharmony-arm64": "0.42.0", - "@oxfmt/binding-win32-arm64-msvc": "0.42.0", - "@oxfmt/binding-win32-ia32-msvc": "0.42.0", - "@oxfmt/binding-win32-x64-msvc": "0.42.0" + "@oxfmt/binding-android-arm-eabi": "0.48.0", + "@oxfmt/binding-android-arm64": "0.48.0", + "@oxfmt/binding-darwin-arm64": "0.48.0", + "@oxfmt/binding-darwin-x64": "0.48.0", + "@oxfmt/binding-freebsd-x64": "0.48.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.48.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.48.0", + "@oxfmt/binding-linux-arm64-gnu": "0.48.0", + "@oxfmt/binding-linux-arm64-musl": "0.48.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.48.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.48.0", + "@oxfmt/binding-linux-riscv64-musl": "0.48.0", + "@oxfmt/binding-linux-s390x-gnu": "0.48.0", + "@oxfmt/binding-linux-x64-gnu": "0.48.0", + "@oxfmt/binding-linux-x64-musl": "0.48.0", + "@oxfmt/binding-openharmony-arm64": "0.48.0", + "@oxfmt/binding-win32-arm64-msvc": "0.48.0", + "@oxfmt/binding-win32-ia32-msvc": "0.48.0", + "@oxfmt/binding-win32-x64-msvc": "0.48.0" } }, "node_modules/oxlint": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.57.0.tgz", - "integrity": "sha512-DGFsuBX5MFZX9yiDdtKjTrYPq45CZ8Fft6qCltJITYZxfwYjVdGf/6wycGYTACloauwIPxUnYhBVeZbHvleGhw==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.63.0.tgz", + "integrity": "sha512-9TGXetdjgIHOJ9OiReomP7nnrMkV9HxC1xM2ramJSLQpzxjsAJtQwa4wqkJN2f/uCrqZuJseFuSlWDdvcruveg==", "dev": true, "license": "MIT", "bin": { @@ -4239,28 +3689,28 @@ "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxlint/binding-android-arm-eabi": "1.57.0", - "@oxlint/binding-android-arm64": "1.57.0", - "@oxlint/binding-darwin-arm64": "1.57.0", - "@oxlint/binding-darwin-x64": "1.57.0", - "@oxlint/binding-freebsd-x64": "1.57.0", - "@oxlint/binding-linux-arm-gnueabihf": "1.57.0", - "@oxlint/binding-linux-arm-musleabihf": "1.57.0", - "@oxlint/binding-linux-arm64-gnu": "1.57.0", - "@oxlint/binding-linux-arm64-musl": "1.57.0", - "@oxlint/binding-linux-ppc64-gnu": "1.57.0", - "@oxlint/binding-linux-riscv64-gnu": "1.57.0", - "@oxlint/binding-linux-riscv64-musl": "1.57.0", - "@oxlint/binding-linux-s390x-gnu": "1.57.0", - "@oxlint/binding-linux-x64-gnu": "1.57.0", - "@oxlint/binding-linux-x64-musl": "1.57.0", - "@oxlint/binding-openharmony-arm64": "1.57.0", - "@oxlint/binding-win32-arm64-msvc": "1.57.0", - "@oxlint/binding-win32-ia32-msvc": "1.57.0", - "@oxlint/binding-win32-x64-msvc": "1.57.0" + "@oxlint/binding-android-arm-eabi": "1.63.0", + "@oxlint/binding-android-arm64": "1.63.0", + "@oxlint/binding-darwin-arm64": "1.63.0", + "@oxlint/binding-darwin-x64": "1.63.0", + "@oxlint/binding-freebsd-x64": "1.63.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.63.0", + "@oxlint/binding-linux-arm-musleabihf": "1.63.0", + "@oxlint/binding-linux-arm64-gnu": "1.63.0", + "@oxlint/binding-linux-arm64-musl": "1.63.0", + "@oxlint/binding-linux-ppc64-gnu": "1.63.0", + "@oxlint/binding-linux-riscv64-gnu": "1.63.0", + "@oxlint/binding-linux-riscv64-musl": "1.63.0", + "@oxlint/binding-linux-s390x-gnu": "1.63.0", + "@oxlint/binding-linux-x64-gnu": "1.63.0", + "@oxlint/binding-linux-x64-musl": "1.63.0", + "@oxlint/binding-openharmony-arm64": "1.63.0", + "@oxlint/binding-win32-arm64-msvc": "1.63.0", + "@oxlint/binding-win32-ia32-msvc": "1.63.0", + "@oxlint/binding-win32-x64-msvc": "1.63.0" }, "peerDependencies": { - "oxlint-tsgolint": ">=0.15.0" + "oxlint-tsgolint": ">=0.22.1" }, "peerDependenciesMeta": { "oxlint-tsgolint": { @@ -4269,21 +3719,21 @@ } }, "node_modules/oxlint-tsgolint": { - "version": "0.18.1", - "resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-0.18.1.tgz", - "integrity": "sha512-Hgb0wMfuXBYL0ddY+1hAG8IIfC40ADwPnBuUaC6ENAuCtTF4dHwsy7mCYtQ2e7LoGvfoSJRY0+kqQRiembJ/jQ==", + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/oxlint-tsgolint/-/oxlint-tsgolint-0.22.1.tgz", + "integrity": "sha512-YUSGSLUnoolsu8gxISEDio3q1rtsCozwfOzASUn3DT2mR2EeQ93uEEnen7s+6LpF+lyTQFln1pQfqwBh/fsVEg==", "dev": true, "license": "MIT", "bin": { "tsgolint": "bin/tsgolint.js" }, "optionalDependencies": { - "@oxlint-tsgolint/darwin-arm64": "0.18.1", - "@oxlint-tsgolint/darwin-x64": "0.18.1", - "@oxlint-tsgolint/linux-arm64": "0.18.1", - "@oxlint-tsgolint/linux-x64": "0.18.1", - "@oxlint-tsgolint/win32-arm64": "0.18.1", - "@oxlint-tsgolint/win32-x64": "0.18.1" + "@oxlint-tsgolint/darwin-arm64": "0.22.1", + "@oxlint-tsgolint/darwin-x64": "0.22.1", + "@oxlint-tsgolint/linux-arm64": "0.22.1", + "@oxlint-tsgolint/linux-x64": "0.22.1", + "@oxlint-tsgolint/win32-arm64": "0.22.1", + "@oxlint-tsgolint/win32-x64": "0.22.1" } }, "node_modules/picocolors": { @@ -4305,13 +3755,13 @@ } }, "node_modules/playwright": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", - "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.57.0" + "playwright-core": "1.59.1" }, "bin": { "playwright": "cli.js" @@ -4324,9 +3774,9 @@ } }, "node_modules/playwright-core": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", - "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -4336,25 +3786,10 @@ "node": ">=18" } }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "funding": [ { "type": "opencollective", @@ -4401,9 +3836,9 @@ "license": "MIT" }, "node_modules/react": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", - "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4422,22 +3857,43 @@ "react-dom": ">=16.8.0" } }, + "node_modules/react-aria": { + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.48.0.tgz", + "integrity": "sha512-jQjd4rBEIMqecBaAKYJbVGK6EqIHLa5znVQ7jwFyK5vCyljoj6KhgtiahmcIPsG5vG5vEDLw+ba+bEWn6A2P4w==", + "license": "Apache-2.0", + "dependencies": { + "@internationalized/date": "^3.12.1", + "@internationalized/number": "^3.6.6", + "@internationalized/string": "^3.2.8", + "@react-types/shared": "^3.34.0", + "@swc/helpers": "^0.5.0", + "aria-hidden": "^1.2.3", + "clsx": "^2.0.0", + "react-stately": "3.46.0", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, "node_modules/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.3" + "react": "^19.2.5" } }, "node_modules/react-hook-form": { - "version": "7.68.0", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.68.0.tgz", - "integrity": "sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q==", + "version": "7.75.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.75.0.tgz", + "integrity": "sha512-Ovv94H+0p3sJ7B9B5QxPuCP1u8V/cHuVGyH55cSwodYDtoJwK+fqk3vjfIgSX59I2U/bU4z0nRJ9HMLpNiWEmw==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -4468,18 +3924,18 @@ } }, "node_modules/react-icons": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", - "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz", + "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==", "license": "MIT", "peerDependencies": { "react": "*" } }, "node_modules/react-is": { - "version": "19.2.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.1.tgz", - "integrity": "sha512-L7BnWgRbMwzMAubQcS7sXdPdNLmKlucPlopgAzx7FtYbksWZgEWiuYM5x9T6UqS2Ne0rsgQTq5kY2SGqpzUkYA==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", + "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", "license": "MIT", "peer": true }, @@ -4507,9 +3963,9 @@ } }, "node_modules/react-router": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.10.1.tgz", - "integrity": "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==", + "version": "7.15.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz", + "integrity": "sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -4528,14 +3984,21 @@ } } }, - "node_modules/react-simple-keyboard": { - "version": "3.8.141", - "resolved": "https://registry.npmjs.org/react-simple-keyboard/-/react-simple-keyboard-3.8.141.tgz", - "integrity": "sha512-r8KPlfvacxYTRWnw5J0yRQ/LmLtbExfPslRjYVFRRvXBBFFhyoWVq0CozEd3qfW1vcEdO+uNu/OnooGuTqgCBQ==", - "license": "MIT", + "node_modules/react-stately": { + "version": "3.46.0", + "resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.46.0.tgz", + "integrity": "sha512-OdxhWvHgs2L4OJGIs7hnuTr5WjjMM6enhNEAMRqiekhF8+ITvA2LRwNftOZwcogaoCslGYq5S2VQTQwnm0GbCA==", + "license": "Apache-2.0", + "dependencies": { + "@internationalized/date": "^3.12.1", + "@internationalized/number": "^3.6.6", + "@internationalized/string": "^3.2.8", + "@react-types/shared": "^3.34.0", + "@swc/helpers": "^0.5.0", + "use-sync-external-store": "^1.6.0" + }, "peerDependencies": { - "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "node_modules/react-use-websocket": { @@ -4545,24 +4008,24 @@ "license": "MIT" }, "node_modules/react-xtermjs": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/react-xtermjs/-/react-xtermjs-1.0.10.tgz", - "integrity": "sha512-+xpKEKbmsypWzRKE0FR1LNIGcI8gx+R6VMHe8IQW7iTbgeqp3Qg7SbiVNOzR+Ovb1QK4DPA3KqsIQV+XP0iRUA==", + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/react-xtermjs/-/react-xtermjs-1.0.11.tgz", + "integrity": "sha512-GyC4krigc+LREQKM25aFRCZf5kmUmx+xgEB2X0XEwZXYh/2QHeegHfdBP+oFztPgKsSROa1OIW4wqQzSCKSP+Q==", "license": "ISC", "peerDependencies": { "@xterm/xterm": "^5.5.0" } }, "node_modules/recharts": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.5.1.tgz", - "integrity": "sha512-+v+HJojK7gnEgG6h+b2u7k8HH7FhyFUzAc4+cPrsjL4Otdgqr/ecXzAnHciqlzV1ko064eNcsdzrYOM78kankA==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", "license": "MIT", "workspaces": [ "www" ], "dependencies": { - "@reduxjs/toolkit": "1.x.x || 2.x.x", + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", @@ -4635,13 +4098,13 @@ "license": "MIT" }, "node_modules/rolldown": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", - "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.122.0", - "@rolldown/pluginutils": "1.0.0-rc.12" + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" }, "bin": { "rolldown": "bin/cli.mjs" @@ -4650,27 +4113,27 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-x64": "1.0.0-rc.12", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" } }, "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", - "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", "license": "MIT" }, "node_modules/scheduler": { @@ -4680,9 +4143,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -4721,23 +4184,10 @@ "is-fullwidth-code-point": "^5.1.0" }, "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" + "node": ">=20" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, "node_modules/source-map-js": { @@ -4772,9 +4222,9 @@ } }, "node_modules/string-width": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", - "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", "dev": true, "license": "MIT", "dependencies": { @@ -4805,15 +4255,15 @@ } }, "node_modules/tabbable": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.3.0.tgz", - "integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", "license": "MIT" }, "node_modules/tailwind-merge": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", - "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", "license": "MIT", "funding": { "type": "github", @@ -4821,15 +4271,15 @@ } }, "node_modules/tailwindcss": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", - "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", + "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", "license": "MIT" }, "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", "engines": { @@ -4871,9 +4321,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", - "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", "dev": true, "license": "MIT", "engines": { @@ -4881,13 +4331,13 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -4945,9 +4395,9 @@ } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "devOptional": true, "license": "MIT" }, @@ -4968,9 +4418,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", - "integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -5037,9 +4487,9 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", "dev": true, "funding": [ "https://github.com/sponsors/broofa", @@ -5047,13 +4497,13 @@ ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/validator": { - "version": "13.15.23", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.23.tgz", - "integrity": "sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw==", + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", "license": "MIT", "engines": { "node": ">= 0.10" @@ -5082,16 +4532,16 @@ } }, "node_modules/vite": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", - "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.12", - "tinyglobby": "^0.2.15" + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" }, "bin": { "vite": "bin/vite.js" @@ -5108,7 +4558,7 @@ "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", @@ -5158,253 +4608,18 @@ } } }, - "node_modules/vite/node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/vite/node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ "darwin" ], "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, "node_modules/wasm-feature-detect": { @@ -5454,19 +4669,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/wrap-ansi/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -5486,9 +4688,9 @@ } }, "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", "devOptional": true, "license": "ISC", "bin": { diff --git a/ui/package.json b/ui/package.json index b2b8506eb..209e0be3c 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,6 +1,6 @@ { "name": "kvm-ui", - "version": "2025.12.11.1200", + "version": "2026.05.05.1730", "private": true, "type": "module", "scripts": { @@ -15,6 +15,7 @@ "lint:only": "oxlint ./src", "lint:fix": "npm run i18n:compile && npm run lint:fixonly", "lint:fixonly": "oxlint ./src --fix", + "lint-staged": "lint-staged", "i18n": "npm run i18n:resort && npm run i18n:validate && npm run i18n:compile", "i18n:resort": "python3 tools/resort_messages.py", "i18n:validate": "inlang validate --project ./localization/jetKVM.UI.inlang", @@ -28,7 +29,7 @@ "prepare": "cd .. && husky ui/.husky" }, "dependencies": { - "@headlessui/react": "^2.2.9", + "@headlessui/react": "^2.2.10", "@headlessui/tailwindcss": "^0.2.2", "@heroicons/react": "^2.2.0", "@vitejs/plugin-basic-ssl": "^2.3.0", @@ -39,56 +40,55 @@ "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", "cva": "^1.0.0-beta.4", - "dayjs": "^1.11.19", - "focus-trap-react": "^11.0.4", - "framer-motion": "^12.23.26", + "dayjs": "^1.11.20", + "focus-trap-react": "^12.0.1", + "framer-motion": "^12.38.0", "mini-svg-data-uri": "^1.4.4", - "react": "^19.2.3", + "react": "^19.2.5", "react-animate-height": "^3.2.3", - "react-dom": "^19.2.3", - "react-hook-form": "^7.68.0", + "react-dom": "^19.2.5", + "react-hook-form": "^7.75.0", "react-hot-toast": "^2.6.0", - "react-icons": "^5.5.0", - "react-router": "^7.10.1", - "react-simple-keyboard": "^3.8.141", + "react-icons": "^5.6.0", + "react-router": "^7.15.0", "react-use-websocket": "^4.13.0", - "react-xtermjs": "^1.0.10", - "recharts": "^3.5.1", - "semver": "^7.7.3", - "tailwind-merge": "^3.4.0", + "react-xtermjs": "^1.0.11", + "recharts": "^3.8.1", + "semver": "^7.7.4", + "tailwind-merge": "^3.5.0", "tesseract.js": "^7.0.0", "tslog": "^4.10.2", "usehooks-ts": "^3.1.1", - "validator": "^13.15.23", + "validator": "^13.15.35", "zustand": "^4.5.2" }, "devDependencies": { - "@inlang/cli": "^3.0.12", - "@inlang/paraglide-js": "^2.6.0", - "@inlang/plugin-m-function-matcher": "^2.1.0", - "@inlang/plugin-message-format": "^4.0.0", - "@inlang/sdk": "^2.4.9", - "@playwright/test": "^1.49.0", - "@tailwindcss/forms": "^0.5.10", - "@tailwindcss/postcss": "^4.1.18", + "@inlang/cli": "^3.1.11", + "@inlang/paraglide-js": "^2.18.0", + "@inlang/plugin-m-function-matcher": "^2.2.6", + "@inlang/plugin-message-format": "^4.4.0", + "@inlang/sdk": "^2.9.3", + "@playwright/test": "^1.59.1", + "@tailwindcss/forms": "^0.5.11", + "@tailwindcss/postcss": "^4.2.4", "@tailwindcss/typography": "^0.5.19", - "@tailwindcss/vite": "^4.2.2", - "@types/node": "^24.10.1", - "@types/react": "^19.2.7", + "@tailwindcss/vite": "^4.2.4", + "@types/node": "^25.6.0", + "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/semver": "^7.7.1", "@types/validator": "^13.15.10", "@vitejs/plugin-react-swc": "^4.3.0", - "autoprefixer": "^10.4.23", + "autoprefixer": "^10.5.0", "husky": "^9.1.7", "lint-staged": "^16.4.0", - "oxfmt": "^0.42.0", - "oxlint": "^1.57.0", - "oxlint-tsgolint": "^0.18.1", - "postcss": "^8.5.6", - "tailwindcss": "^4.1.18", + "oxfmt": "^0.48.0", + "oxlint": "^1.63.0", + "oxlint-tsgolint": "^0.22.1", + "postcss": "^8.5.14", + "tailwindcss": "^4.2.4", "typescript": "^5.9.3", - "vite": "^8.0.3" + "vite": "^8.0.10" }, "lint-staged": { "*.{ts,tsx}": "oxlint --fix --deny-warnings", diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts index bd8f95b78..5bf6a5570 100644 --- a/ui/playwright.config.ts +++ b/ui/playwright.config.ts @@ -31,6 +31,18 @@ export default defineConfig({ testDir: "./e2e/remote-agent", testMatch: "ra-all.spec.ts", }, + { + name: "keyboard-paste", + testDir: "./e2e/remote-agent", + testMatch: /keyboard-paste\.spec\.ts/, + dependencies: ["remote-agent"], + }, + { + name: "keyboard-macros", + testDir: "./e2e/remote-agent", + testMatch: /keyboard-macros\.spec\.ts/, + dependencies: ["remote-agent"], + }, { name: "ota-signed", testMatch: /ota-signature\.spec\.ts/, dependencies: ["remote-agent"] }, { name: "ota-prerelease-unsigned", diff --git a/ui/src/components/DhcpLeaseCard.tsx b/ui/src/components/DhcpLeaseCard.tsx index 1143b0ae7..b3cca5e15 100644 --- a/ui/src/components/DhcpLeaseCard.tsx +++ b/ui/src/components/DhcpLeaseCard.tsx @@ -162,7 +162,9 @@ export default function DhcpLeaseCard({   - + )} diff --git a/ui/src/components/MacroForm.tsx b/ui/src/components/MacroForm.tsx index c299e26ac..1eb38571e 100644 --- a/ui/src/components/MacroForm.tsx +++ b/ui/src/components/MacroForm.tsx @@ -1,8 +1,10 @@ -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { LuPlus } from "react-icons/lu"; -import { KeySequence } from "@hooks/stores"; -import useKeyboardLayout from "@hooks/useKeyboardLayout"; +import { KeySequence, useSettingsStore } from "@hooks/stores"; +import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc"; +import type { KeyboardLayout } from "@components/keyboard/types/schema"; +import { buildKeyDisplayMap } from "@/keyDisplayNames"; import { Button } from "@components/Button"; import FieldLabel from "@components/FieldLabel"; import Fieldset from "@components/Fieldset"; @@ -11,6 +13,8 @@ import { MacroStepCard } from "@components/MacroStepCard"; import { DEFAULT_DELAY, MAX_STEPS_PER_MACRO, MAX_KEYS_PER_STEP } from "@/constants/macros"; import { m } from "@localizations/messages.js"; +import "@components/keyboard/virtual-keyboard.css"; + interface ValidationErrors { name?: string; steps?: Record< @@ -40,7 +44,20 @@ export function MacroForm({ const [keyQueries, setKeyQueries] = useState>({}); const [errors, setErrors] = useState({}); const [errorMessage, setErrorMessage] = useState(null); - const { selectedKeyboard } = useKeyboardLayout(); + + const { send } = useJsonRpc(); + const { keyboardLayout } = useSettingsStore(); + const [kleLayout, setKleLayout] = useState(null); + + useEffect(() => { + if (!keyboardLayout) return; + void send("getKeyboardLayoutData", { id: keyboardLayout }, (resp: JsonRpcResponse) => { + if ("error" in resp) return; + setKleLayout(resp.result as KeyboardLayout); + }); + }, [send, keyboardLayout]); + + const keyDisplayMap = useMemo(() => buildKeyDisplayMap(kleLayout), [kleLayout]); const showTemporaryError = (message: string) => { setErrorMessage(message); @@ -232,13 +249,13 @@ export function MacroForm({ onModifierChange={modifiers => handleModifierChange(stepIndex, modifiers)} onDelayChange={delay => handleDelayChange(stepIndex, delay)} isLastStep={stepIndex === (macro.steps?.length || 0) - 1} - keyboard={selectedKeyboard} + keyDisplayMap={keyDisplayMap} /> ))} -
+
+ + ))} +
+
+ ))} + + + ); +} diff --git a/ui/src/components/Terminal.tsx b/ui/src/components/Terminal.tsx index b22f29527..61fd179bb 100644 --- a/ui/src/components/Terminal.tsx +++ b/ui/src/components/Terminal.tsx @@ -1,4 +1,3 @@ -import "react-simple-keyboard/build/css/index.css"; import { ChevronDownIcon, PauseCircleIcon, PlayCircleIcon } from "@heroicons/react/16/solid"; import { useEffect, useMemo, useCallback, useState } from "react"; import { useXTerm } from "react-xtermjs"; diff --git a/ui/src/components/VirtualKeyboard.tsx b/ui/src/components/VirtualKeyboard.tsx index c63d32743..d864239f2 100644 --- a/ui/src/components/VirtualKeyboard.tsx +++ b/ui/src/components/VirtualKeyboard.tsx @@ -1,93 +1,282 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ChevronDownIcon } from "@heroicons/react/16/solid"; import { AnimatePresence, motion } from "framer-motion"; -import { KeyboardReact as Keyboard } from "react-simple-keyboard"; import { LuKeyboard } from "react-icons/lu"; -import "react-simple-keyboard/build/css/index.css"; import DetachIconRaw from "@/assets/detach-icon.svg"; import { cx } from "@/cva.config"; -import { useHidStore, useUiStore } from "@hooks/stores"; +import { useHidStore, useSettingsStore, useUiStore } from "@hooks/stores"; import useKeyboard from "@hooks/useKeyboard"; -import useKeyboardLayout from "@hooks/useKeyboardLayout"; import { Button, LinkButton } from "@components/Button"; import Card from "@components/Card"; -import { decodeModifiers, keys, latchingKeys, modifiers } from "@/keyboardMappings"; +import { useJsonRpc, JsonRpcResponse } from "@hooks/useJsonRpc"; +import { VirtualKeyboard as KleKeyboard } from "@components/keyboard/VirtualKeyboard"; +import { QuickActions } from "@components/QuickActions"; +import type { KeyboardLayout } from "@components/keyboard/types/schema"; +import { isModifierScancode, hidKeyToModifierMask } from "@/keyboardMappings"; import { m } from "@localizations/messages.js"; +import "@components/keyboard/virtual-keyboard.css"; + export const DetachIcon = ({ className }: { className?: string }) => { return Detach Icon; }; +// Reverse map: modifier bit → HID scancode (for decoding keysDownState.modifier) +const modifierBitToScancode: [number, number][] = Object.entries(hidKeyToModifierMask).map( + ([sc, bit]) => [bit as number, Number(sc)], +); + +type ResizeDirection = + | "left" + | "right" + | "top" + | "bottom" + | "top-left" + | "top-right" + | "bottom-left" + | "bottom-right"; + +const DETACHED_MIN_WIDTH = 600; +const DETACHED_MAX_WIDTH = 1800; + function KeyboardWrapper() { const keyboardRef = useRef(null); const { isAttachedVirtualKeyboardVisible, setAttachedVirtualKeyboardVisibility } = useUiStore(); - const { keyboardLedState, keysDownState, isVirtualKeyboardEnabled, setVirtualKeyboardEnabled } = + const { keysDownState, keyboardLedState, isVirtualKeyboardEnabled, setVirtualKeyboardEnabled } = useHidStore(); - const { handleKeyPress, executeMacro } = useKeyboard(); - const { selectedKeyboard } = useKeyboardLayout(); + const { handleKeyPress, pressLatchedModifier, releaseLatchedModifier, executeMacro } = + useKeyboard(); + const { keyboardLayout, modifierLatching } = useSettingsStore(); + const { send } = useJsonRpc(); + + // KLE layout fetched from backend + const [kleLayout, setKleLayout] = useState(null); + // Dragging state for detached mode const [isDragging, setIsDragging] = useState(false); + const [isResizing, setIsResizing] = useState(false); const [position, setPosition] = useState({ x: 0, y: 0 }); const [newPosition, setNewPosition] = useState({ x: 0, y: 0 }); + const [detachedWidth, setDetachedWidth] = useState(null); + const resizeDirectionRef = useRef(null); + const resizeStartRef = useRef({ + x: 0, + y: 0, + width: 0, + height: 0, + pointerX: 0, + pointerY: 0, + }); + + // Track which modifiers the virtual keyboard has latched. + // This provides immediate visual feedback without waiting + // for the HID round-trip via keysDownState. + const [latchedModifiers, setLatchedModifiers] = useState>(new Set()); + const latchedModifiersRef = useRef(latchedModifiers); + latchedModifiersRef.current = latchedModifiers; + + // --------------------------------------------------------------------------- + // Fetch KLE layout from backend when keyboard layout changes + // --------------------------------------------------------------------------- + + useEffect(() => { + if (!keyboardLayout) return; - const keyDisplayMap = useMemo(() => { - return selectedKeyboard.keyDisplayMap; - }, [selectedKeyboard]); - - const virtualKeyboard = useMemo(() => { - return selectedKeyboard.virtualKeyboard; - }, [selectedKeyboard]); - - const { isShiftActive } = useMemo(() => { - return decodeModifiers(keysDownState.modifier); - }, [keysDownState]); - - const isCapsLockActive = useMemo(() => { - return keyboardLedState.caps_lock; - }, [keyboardLedState]); - - const mainLayoutName = useMemo(() => { - // if you have the CapsLock "latched", then the shift state is inverted - const effectiveShift = isCapsLockActive ? false === isShiftActive : isShiftActive; - return effectiveShift ? "shift" : "default"; - }, [isCapsLockActive, isShiftActive]); - - const keyNamesForDownKeys = useMemo(() => { - const activeModifierMask = keysDownState.modifier || 0; - const modifierNames = Object.entries(modifiers) - .filter(([_, mask]) => (activeModifierMask & mask) !== 0) - .map(([name, _]) => name); - - const keysDown = keysDownState.keys || []; - const keyNames = Object.entries(keys) - .filter(([_, value]) => keysDown.includes(value)) - .map(([name, _]) => name); - - return [...modifierNames, ...keyNames, " "]; // we have to have at least one space to avoid keyboard whining - }, [keysDownState]); - - const startDrag = useCallback((e: MouseEvent | TouchEvent) => { - if (!keyboardRef.current) return; - if (e instanceof TouchEvent && e.touches.length > 1) return; - setIsDragging(true); - - const clientX = e instanceof TouchEvent ? e.touches[0].clientX : e.clientX; - const clientY = e instanceof TouchEvent ? e.touches[0].clientY : e.clientY; - - const rect = keyboardRef.current.getBoundingClientRect(); - setPosition({ - x: clientX - rect.left, - y: clientY - rect.top, + void send("getKeyboardLayoutData", { id: keyboardLayout }, (resp: JsonRpcResponse) => { + if ("error" in resp) { + setKleLayout(null); + return; + } + setKleLayout(resp.result as KeyboardLayout); }); - }, []); + }, [send, keyboardLayout]); + + // --------------------------------------------------------------------------- + // Pressed scancodes — derived entirely from keysDownState (single source of truth) + // --------------------------------------------------------------------------- + + const pressedScancodes = useMemo(() => { + const set = new Set(); + + // Non-modifier keys from the HID key buffer + if (keysDownState.keys) { + for (const k of keysDownState.keys) { + if (k !== 0) set.add(k); + } + } + + // Decode modifier byte into individual scancodes + if (keysDownState.modifier) { + for (const [bit, scancode] of modifierBitToScancode) { + if (keysDownState.modifier & bit) { + set.add(scancode); + } + } + } + + // Merge optimistic latch state for immediate visual feedback + for (const sc of latchedModifiers) { + set.add(sc); + } + + return set; + }, [keysDownState, latchedModifiers]); + + // --------------------------------------------------------------------------- + // Key send handler — bridges the KLE keyboard to the HID layer + // --------------------------------------------------------------------------- + + const onKeySend = useCallback( + (scancode: number) => { + if (scancode === 0) return; - const onDrag = useCallback( + // Regular key (or non-latching modifier): press then release. + if (!modifierLatching || !isModifierScancode(scancode)) { + void handleKeyPress(scancode, true); + setTimeout(() => void handleKeyPress(scancode, false), 50); + return; + } + + // Latch mode: click to toggle on/off. + // Read from ref so this stays stable across latch toggles + const wasLatched = latchedModifiersRef.current.has(scancode); + setLatchedModifiers(current => { + const next = new Set(current); + if (wasLatched) { + next.delete(scancode); + } else { + next.add(scancode); + } + return next; + }); + + if (wasLatched) { + releaseLatchedModifier(scancode); + } else { + pressLatchedModifier(scancode); + } + }, + [handleKeyPress, modifierLatching, pressLatchedModifier, releaseLatchedModifier], + ); + + // --------------------------------------------------------------------------- + // Drag handling (for detached/floating mode) + // --------------------------------------------------------------------------- + + const getClientPoint = (e: MouseEvent | TouchEvent) => { + if (e instanceof TouchEvent) { + if (e.touches.length === 0) return null; + return { x: e.touches[0].clientX, y: e.touches[0].clientY }; + } + return { x: e.clientX, y: e.clientY }; + }; + + const startDrag = useCallback( + (e: MouseEvent | TouchEvent) => { + if (!keyboardRef.current) return; + if (isResizing) return; + const target = e.target as HTMLElement | null; + if (target?.closest('[data-resize-handle="true"]')) return; + if (e instanceof TouchEvent && e.touches.length > 1) return; + setIsDragging(true); + + const point = getClientPoint(e); + if (!point) return; + + const rect = keyboardRef.current.getBoundingClientRect(); + setPosition({ + x: point.x - rect.left, + y: point.y - rect.top, + }); + }, + [isResizing], + ); + + const startResize = useCallback( + (direction: ResizeDirection, e: MouseEvent | TouchEvent) => { + if (!keyboardRef.current) return; + if (e instanceof TouchEvent && e.touches.length > 1) return; + + const point = getClientPoint(e); + if (!point) return; + + const rect = keyboardRef.current.getBoundingClientRect(); + resizeDirectionRef.current = direction; + resizeStartRef.current = { + x: newPosition.x, + y: newPosition.y, + width: rect.width, + height: rect.height, + pointerX: point.x, + pointerY: point.y, + }; + setIsResizing(true); + setIsDragging(false); + }, + [newPosition.x, newPosition.y], + ); + + const onPointerMove = useCallback( (e: MouseEvent | TouchEvent) => { if (!keyboardRef.current) return; + const point = getClientPoint(e); + if (!point) return; + + if (isResizing && resizeDirectionRef.current) { + const start = resizeStartRef.current; + const dir = resizeDirectionRef.current; + + const dx = point.x - start.pointerX; + const dy = point.y - start.pointerY; + + const hasLeft = dir.includes("left"); + const hasRight = dir.includes("right"); + const hasTop = dir.includes("top"); + const hasBottom = dir.includes("bottom"); + + const aspect = start.height > 0 ? start.width / start.height : 1; + const fromX = (hasLeft ? -dx : 0) + (hasRight ? dx : 0); + const fromY = ((hasTop ? -dy : 0) + (hasBottom ? dy : 0)) * aspect; + + let deltaW = fromX; + if ((hasLeft || hasRight) && (hasTop || hasBottom)) { + deltaW = (fromX + fromY) / 2; + } else if (hasTop || hasBottom) { + deltaW = fromY; + } + + const nextWidth = Math.max( + DETACHED_MIN_WIDTH, + Math.min(DETACHED_MAX_WIDTH, start.width + deltaW), + ); + const scale = nextWidth / start.width; + const nextHeight = start.height * scale; + + let nextX = start.x; + let nextY = start.y; + + if (hasLeft) { + nextX = start.x + (start.width - nextWidth); + } + if (hasTop) { + nextY = start.y + (start.height - nextHeight); + } + + const maxX = window.innerWidth - nextWidth; + const maxY = window.innerHeight - nextHeight; + + setDetachedWidth(nextWidth); + setNewPosition({ + x: Math.min(Math.max(nextX, 0), Math.max(maxX, 0)), + y: Math.min(Math.max(nextY, 0), Math.max(maxY, 0)), + }); + return; + } + if (isDragging) { - const clientX = e instanceof TouchEvent ? e.touches[0].clientX : e.clientX; - const clientY = e instanceof TouchEvent ? e.touches[0].clientY : e.clientY; + const clientX = point.x; + const clientY = point.y; const newX = clientX - position.x; const newY = clientY - position.y; @@ -102,15 +291,43 @@ function KeyboardWrapper() { }); } }, - [isDragging, position.x, position.y], + [isDragging, isResizing, position.x, position.y], ); const endDrag = useCallback(() => { setIsDragging(false); + setIsResizing(false); + resizeDirectionRef.current = null; }, []); + const onResizeHandleMouseDown = useCallback( + (direction: ResizeDirection) => (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + startResize(direction, e.nativeEvent); + }, + [startResize], + ); + + const onResizeHandleTouchStart = useCallback( + (direction: ResizeDirection) => (e: React.TouchEvent) => { + e.preventDefault(); + e.stopPropagation(); + startResize(direction, e.nativeEvent); + }, + [startResize], + ); + + useEffect(() => { + if (!isAttachedVirtualKeyboardVisible && keyboardRef.current && detachedWidth == null) { + const rect = keyboardRef.current.getBoundingClientRect(); + if (rect.width > 0) { + setDetachedWidth(Math.max(DETACHED_MIN_WIDTH, Math.min(DETACHED_MAX_WIDTH, rect.width))); + } + } + }, [isAttachedVirtualKeyboardVisible, detachedWidth]); + useEffect(() => { - // Is the keyboard detached or attached? if (isAttachedVirtualKeyboardVisible) return; const handle = keyboardRef.current; @@ -122,8 +339,8 @@ function KeyboardWrapper() { document.addEventListener("mouseup", endDrag); document.addEventListener("touchend", endDrag); - document.addEventListener("mousemove", onDrag); - document.addEventListener("touchmove", onDrag); + document.addEventListener("mousemove", onPointerMove); + document.addEventListener("touchmove", onPointerMove); return () => { if (handle) { @@ -134,67 +351,14 @@ function KeyboardWrapper() { document.removeEventListener("mouseup", endDrag); document.removeEventListener("touchend", endDrag); - document.removeEventListener("mousemove", onDrag); - document.removeEventListener("touchmove", onDrag); + document.removeEventListener("mousemove", onPointerMove); + document.removeEventListener("touchmove", onPointerMove); }; - }, [isAttachedVirtualKeyboardVisible, endDrag, onDrag, startDrag]); - - const onKeyUp = useCallback(async (_: string, e: MouseEvent | undefined) => { - e?.preventDefault(); - e?.stopPropagation(); - }, []); - - const onKeyDown = useCallback( - async (key: string, e: MouseEvent | undefined) => { - e?.preventDefault(); - e?.stopPropagation(); - - // handle the fake key-macros we have defined for common combinations - if (key === "CtrlAltDelete") { - await executeMacro([ - { keys: ["Delete"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 }, - ]); - return; - } - - if (key === "AltMetaEscape") { - await executeMacro([{ keys: ["Escape"], modifiers: ["AltLeft", "MetaLeft"], delay: 100 }]); - return; - } - - if (key === "CtrlAltBackspace") { - await executeMacro([ - { keys: ["Backspace"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 }, - ]); - return; - } - - // if they press any of the latching keys, we send a keypress down event and the release it automatically (on timer) - if (latchingKeys.includes(key)) { - console.debug(`Latching key pressed: ${key} sending down and delayed up pair`); - void handleKeyPress(keys[key], true); - setTimeout(() => void handleKeyPress(keys[key], false), 100); - return; - } - - // if they press any of the dynamic keys, we send a keypress down event but we don't release it until they click it again - if (Object.keys(modifiers).includes(key)) { - const currentlyDown = keyNamesForDownKeys.includes(key); - console.debug( - `Dynamic key pressed: ${key} was currently down: ${currentlyDown}, toggling state`, - ); - void handleKeyPress(keys[key], !currentlyDown); - return; - } + }, [isAttachedVirtualKeyboardVisible, endDrag, onPointerMove, startDrag]); - // otherwise, just treat it as a down+up pair - const cleanKey = key.replace(/[()]/g, ""); - console.debug(`Regular key pressed: ${cleanKey} sending down and up pair`); - void handleKeyPress(keys[cleanKey], true); - setTimeout(() => void handleKeyPress(keys[cleanKey], false), 50); - }, - [executeMacro, handleKeyPress, keyNamesForDownKeys], - ); + // --------------------------------------------------------------------------- + // Render + // --------------------------------------------------------------------------- return (
@@ -233,13 +400,14 @@ function KeyboardWrapper() { "keyboard-detached": !isAttachedVirtualKeyboardVisible, })} > -
-
+
+
{isAttachedVirtualKeyboardVisible ? (
-

- {m.virtual_keyboard_header()} -

-
+
+ +
-
+
-
-
- + {kleLayout ? ( + - -
- - + ) : ( +
+ {m.virtual_keyboard_header()}
- {/* TODO add optional number pad */} -
+ )}
+ + {!isAttachedVirtualKeyboardVisible && ( + <> +
+
+
+
+
+
+
+
+ + )}
)} diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx index e475dfa67..0ef5ae70c 100644 --- a/ui/src/components/WebRTCVideo.tsx +++ b/ui/src/components/WebRTCVideo.tsx @@ -17,7 +17,7 @@ import { PointerLockBar, } from "@components/VideoOverlay"; import OcrOverlay from "@components/OcrOverlay"; -import { keys } from "@/keyboardMappings"; +import { keys, isModifierScancode } from "@/keyboardMappings"; import notifications from "@/notifications"; import { m } from "@localizations/messages.js"; @@ -339,7 +339,7 @@ export default function WebRTCVideo({ // event, so we need to clear the keys after a short delay // https://bugs.chromium.org/p/chromium/issues/detail?id=28089 // https://bugzilla.mozilla.org/show_bug.cgi?id=1299553 - if (e.metaKey && hidKey < 0xe0) { + if (e.metaKey && !isModifierScancode(hidKey)) { setTimeout(() => { console.debug(`Forcing the meta key release of associated key: ${hidKey}`); handleKeyPress(hidKey, false); @@ -351,10 +351,13 @@ export default function WebRTCVideo({ if (!isKeyboardLockActive && hidKey === keys.MetaLeft) { // If the left meta key was just pressed and we're not keyboard locked // we'll never see the keyup event because the browser is going to lose - // focus so set a deferred keyup after a short delay + // focus so set a deferred keyup after a short delay. + // Only synthesize the release if focus was actually lost. setTimeout(() => { - console.debug(`Forcing the left meta key release`); - handleKeyPress(hidKey, false); + if (document.visibilityState !== "visible" || !document.hasFocus()) { + console.debug(`Forcing the left meta key release after focus loss`); + handleKeyPress(hidKey, false); + } }, 100); } }, @@ -473,11 +476,17 @@ export default function WebRTCVideo({ const abortController = new AbortController(); const signal = abortController.signal; + const onVisibilityChange = () => { + if (document.visibilityState === "hidden") { + resetKeyboardState(); + } + }; + document.addEventListener("keydown", keyDownHandler, { signal }); document.addEventListener("keyup", keyUpHandler, { signal }); window.addEventListener("blur", resetKeyboardState, { signal }); - document.addEventListener("visibilitychange", resetKeyboardState, { signal }); + document.addEventListener("visibilitychange", onVisibilityChange, { signal }); return () => { abortController.abort(); @@ -606,13 +615,8 @@ export default function WebRTCVideo({
-
- +
+
@@ -634,9 +638,7 @@ export default function WebRTCVideo({
{/* In relative mouse mode and under https, we enable the pointer lock, and to do so we need a bar to show the user to click on the video to enable mouse control */} -
+
{peerConnection?.connectionState == "connected" && !hasConnectionIssues && ( diff --git a/ui/src/components/keyboard/Keycap.tsx b/ui/src/components/keyboard/Keycap.tsx new file mode 100644 index 000000000..07f358d78 --- /dev/null +++ b/ui/src/components/keyboard/Keycap.tsx @@ -0,0 +1,263 @@ +/** + * A single keycap. Consumes TransportKey from the Go backend directly. + * + * The `shape` field is a pre-computed CSS class name ('' | 'iso-enter' | + * 'big-ass-enter' | 'stepped-caps') applied directly to the div. + * + * Layer visibility is CSS-only via data-layer on the parent .vkb element. + * React.memo ensures layer changes do NOT rerender any Keycap instance. + */ + +import React, { memo, useCallback } from "react"; +import { TransportKey, KeyLegends } from "./types/schema"; +import { m } from "@localizations/messages.js"; + +// Shared key-alias taxonomy with the Go backend (which embeds the same file +// at internal/keyboard/keyaliases.json). Each (canonical + alias) string is +// mapped to m.keys_() via ARIA_KEY_TO_FN below — adding a new alias +// means editing only the JSON; adding a new logical key means editing both +// the JSON and ARIA_KEY_TO_FN. +import keyAliases from "../../../../internal/keyboard/keyaliases.json"; + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +export interface KeycapProps { + /** Pre-processed key data from the Go backend via JSON-RPC. */ + transportKey: TransportKey; + + /** Called on pointerdown with the HID scancode to send. */ + onPress: (scancode: number, legends: KeyLegends) => void; + + /** Visual pressed state — controlled externally from keyboard state tracker. */ + isPressed?: boolean; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export const Keycap = memo(function Keycap({ transportKey, onPress, isPressed }: KeycapProps) { + const { + x, + y, + w, + h, + shape, + legends, + scancode, + deadLegends, + homing, + decal, + controlLike, + color, + textColor, + } = transportKey; + + const widthClass = getWidthClass(w); + const isCustomWidth = widthClass === "w-custom"; + + // A key is a "letter" if its normal legend is a single Unicode letter (any script). + // Used by CSS to apply CapsLock layer switching (shift legend for letters only). + const isLetter = legends.normal != null && /^\p{Ll}$/u.test(legends.normal); + + const className = [ + "key", + widthClass, + shape, // '' | 'iso-enter' | 'big-ass-enter' | 'stepped-caps' + homing && "homing", + decal && "decal", + isPressed && "pressed", + isLetter && "letter", + controlLike && "meta-control", + ] + .filter(Boolean) + .join(" "); + + // For ISO Enter (shape with x2/w2), position at x+x2 so the wider top part aligns correctly + const visualX = transportKey.x2 ? x + (transportKey.x2 ?? 0) : x; + + const inlineStyle: React.CSSProperties = { + "--kx": visualX, + "--ky": y, + ...(h !== 1 && { "--kh": h }), + ...(color && { "--key-color": color }), + ...(textColor && { "--key-text-color": textColor }), + ...(isCustomWidth && getCustomWidthStyle(w)), + } as React.CSSProperties; + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + // Prevent focus steal from the main video/session element. + e.preventDefault(); + if (scancode !== 0) { + onPress(scancode, legends); + } + }, + [scancode, legends, onPress], + ); + + const isDeadLegend = (legendType: string): boolean => { + return deadLegends != null && deadLegends.includes(legendType); + }; + + const renderLegend = (text: string | undefined, type: string, normalDupsShift = false) => { + if (!text) return null; + const deadClass = isDeadLegend(type) ? "dead" : ""; + const dupsClass = normalDupsShift ? "nds" : ""; + return ( + + ); + }; + + return ( +
+ {renderLegend(legends.normal, "normal", legends.normal === legends.shift)} + {renderLegend(legends.shift, "shift", legends.shift === legends.normal)} + {renderLegend(legends.altgr, "altgr")} + {renderLegend(legends.shiftAltgr, "shift-altgr")} + {renderLegend(legends.kana, "kana")} + {renderLegend(legends.shiftKana, "shift-kana")} +
+ ); +}); + +// One entry per logical key. Compile-time validation that every ariaKey used +// in keyaliases.json corresponds to a real localization message. +const ARIA_KEY_TO_FN: Record string> = { + alt: m.keys_alt, + altgr: m.keys_altgr, + application: m.keys_application, + arrow_down: m.keys_arrow_down, + arrow_left: m.keys_arrow_left, + arrow_right: m.keys_arrow_right, + arrow_up: m.keys_arrow_up, + backspace: m.keys_backspace, + caps_lock: m.keys_caps_lock, + command: m.keys_command, + control: m.keys_control, + delete: m.keys_delete, + end: m.keys_end, + enter: m.keys_enter, + escape: m.keys_escape, + home: m.keys_home, + insert: m.keys_insert, + menu: m.keys_menu, + meta: m.keys_meta, + num_lock: m.keys_num_lock, + option: m.keys_option, + page_down: m.keys_page_down, + page_up: m.keys_page_up, + pause: m.keys_pause, + print_screen: m.keys_print_screen, + scroll_lock: m.keys_scroll_lock, + shift: m.keys_shift, + space: m.keys_space, + tab: m.keys_tab, +}; + +const KEY_ARIA_NAMES: Record string> = (() => { + const map: Record string> = {}; + for (const sk of keyAliases.specialKeys) { + const fn = ARIA_KEY_TO_FN[sk.ariaKey]; + if (!fn) { + throw new Error( + `keyAliases.json: ariaKey "${sk.ariaKey}" has no localization in ARIA_KEY_TO_FN`, + ); + } + map[sk.canonical] = fn; + for (const alias of sk.aliases) { + map[alias] = fn; + } + } + return map; +})(); + +function resolveKeyName(legend: string): string { + const lookup = KEY_ARIA_NAMES[legend]; + return lookup ? lookup() : legend; +} + +function ariaLabel(legends: KeyLegends): string { + const parts: string[] = []; + if (legends.normal) { + parts.push(resolveKeyName(legends.normal)); + } + if (legends.shift && legends.shift !== legends.normal?.toUpperCase()) { + parts.push(`${m.keys_modifier_shift()}: ${resolveKeyName(legends.shift)}`); + } + if (legends.altgr) { + parts.push(`${m.keys_modifier_altgr()}: ${resolveKeyName(legends.altgr)}`); + } + if (legends.shiftAltgr) { + parts.push(`${m.keys_modifier_altgr_shift()}: ${resolveKeyName(legends.shiftAltgr)}`); + } + if (legends.kana) { + parts.push(`${m.keys_modifier_kana()}: ${resolveKeyName(legends.kana)}`); + } + if (legends.shiftKana) { + parts.push(`${m.keys_modifier_kana_shift()}: ${resolveKeyName(legends.shiftKana)}`); + } + + return parts.join(", ") || "key"; +} + +/** + * Maps KLE width values to CSS class names. + * Values are rounded to 2 decimal places before lookup to handle float drift. + */ +const WIDTH_CLASS_MAP: Record = { + // Standard ANSI/ISO widths + 100: "", // default — no class needed, CSS default applies + 125: "w-125", + 150: "w-150", + 175: "w-175", + 200: "w-200", + 225: "w-225", + 250: "w-250", + 275: "w-275", + 300: "w-300", + 350: "w-350", // JIS spacebar + 400: "w-400", // decal / wide keys + 625: "w-625", // standard spacebar + + // Less common + 600: "w-600", // some 60% spacebars + 700: "w-700", // some WKL spacebars +}; + +/** + * Returns the CSS width class for a given KLE width value. + * Falls back to a data attribute if the width is not in the standard table, + * allowing a CSS custom property fallback in the stylesheet. + */ +export function getWidthClass(w: number): string { + const rounded = Math.round(w * 100); + const cls = WIDTH_CLASS_MAP[rounded]; + if (cls !== undefined) return cls; + // Non-standard width — caller should also set --key-w CSS variable inline + return "w-custom"; +} + +/** + * Given a key with a non-standard width, returns the inline style + * object to set the --key-w custom property so the CSS can size it correctly. + * + * Usage: if getWidthClass() returns 'w-custom', also spread this onto the element's style. + */ +export function getCustomWidthStyle(w: number): React.CSSProperties { + return { "--key-w": w } as React.CSSProperties; +} diff --git a/ui/src/components/keyboard/LayoutPreviewDialog.tsx b/ui/src/components/keyboard/LayoutPreviewDialog.tsx new file mode 100644 index 000000000..b6b342481 --- /dev/null +++ b/ui/src/components/keyboard/LayoutPreviewDialog.tsx @@ -0,0 +1,188 @@ +/** + * LayoutPreviewDialog — modal that shows a keyboard layout preview. + * + * Fetches the full KeyboardLayout by ID and renders it using the KLE + * VirtualKeyboard component. Includes a "Use this layout" action button + * that sets it as the active layout without closing the dialog. + * + * Used from: + * - Upload success flow (preview what was just uploaded) + * - Settings page preview button (preview any layout) + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { LuCheck, LuKeyboard } from "react-icons/lu"; + +import Modal from "@components/Modal"; +import { Button } from "@components/Button"; +import { VirtualKeyboard } from "@components/keyboard/VirtualKeyboard"; +import type { KeyboardLayout } from "@components/keyboard/types/schema"; +import { useSettingsStore } from "@hooks/stores"; +import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc"; +import notifications from "@/notifications"; +import { m } from "@localizations/messages.js"; + +import "@components/keyboard/virtual-keyboard.css"; + +interface LayoutPreviewDialogProps { + /** The layout ID to preview, or null to close */ + layoutId: string | null; + onClose: () => void; +} + +export function LayoutPreviewDialog({ layoutId, onClose }: LayoutPreviewDialogProps) { + const { send } = useJsonRpc(); + const { keyboardLayout, setKeyboardLayout } = useSettingsStore(); + const [layout, setLayout] = useState(null); + const [loading, setLoading] = useState(false); + + const isActive = keyboardLayout === layoutId; + + // Fetch layout data when layoutId changes + useEffect(() => { + if (!layoutId) { + setLayout(null); + return; + } + setLoading(true); + void send("getKeyboardLayoutData", { id: layoutId }, (resp: JsonRpcResponse) => { + setLoading(false); + if ("error" in resp) { + setLayout(null); + return; + } + setLayout(resp.result as KeyboardLayout); + }); + }, [layoutId, send]); + + const handleUseLayout = useCallback(() => { + if (!layoutId) return; + void send("setKeyboardLayout", { layout: layoutId }, (resp: JsonRpcResponse) => { + if ("error" in resp) { + notifications.error( + m.keyboard_layout_error({ error: resp.error.data || "" }), + ); + return; + } + setKeyboardLayout(layoutId); + notifications.success( + m.keyboard_layout_success({ layout: layout?.name ?? layoutId }), + ); + }); + }, [layoutId, layout, send, setKeyboardLayout]); + + // Press flash — briefly highlights the clicked key in the preview + const [pressedKeys, setPressedKeys] = useState>(new Set()); + const timersRef = useRef>>(new Map()); + + const pressedScancodes = useMemo(() => pressedKeys, [pressedKeys]); + + const handlePreviewKeyPress = useCallback((scancode: number) => { + if (scancode === 0) return; + + // Clear existing timer for this key if re-pressed quickly + const existing = timersRef.current.get(scancode); + if (existing) clearTimeout(existing); + + setPressedKeys(prev => new Set(prev).add(scancode)); + + const timer = setTimeout(() => { + setPressedKeys(prev => { + const next = new Set(prev); + next.delete(scancode); + return next; + }); + timersRef.current.delete(scancode); + }, 200); + + timersRef.current.set(scancode, timer); + }, []); + + // Clean up timers on close + useEffect(() => { + if (!layoutId) { + for (const timer of timersRef.current.values()) clearTimeout(timer); + timersRef.current.clear(); + setPressedKeys(new Set()); + } + }, [layoutId]); + + return ( + +
+
+ {/* Header */} +
+
+ +
+

+ {layout?.name ?? layoutId ?? ""} +

+ {layout && ( +

+ {m.keyboard_layout_preview_keys({ count: layout.keys.length })} + {" · "} + {m.keyboard_layout_preview_chars({ count: Object.keys(layout.charMap).length })} + {layout.author && ` · ${layout.author}`} +

+ )} +
+
+
+ {isActive ? ( +
+
+ + {/* Keyboard preview */} +
+ {loading && ( +
+ Loading... +
+ )} + {!loading && layout && ( +
+ +
+ )} + {!loading && !layout && layoutId && ( +
+ Failed to load layout +
+ )} +
+
+
+
+ ); +} diff --git a/ui/src/components/keyboard/VirtualKeyboard.tsx b/ui/src/components/keyboard/VirtualKeyboard.tsx new file mode 100644 index 000000000..bdcc32ee5 --- /dev/null +++ b/ui/src/components/keyboard/VirtualKeyboard.tsx @@ -0,0 +1,99 @@ +/** + * components/keyboard/VirtualKeyboard.tsx + * + * Pure keyboard renderer. Receives a KeyboardLayout and pressedScancodes, + * renders keycaps, derives the display layer from modifier state. + * + * All state management (latching, HID sends, modifier tracking) lives in + * the parent wrapper — this component is stateless except for the derived layer. + */ + +import React, { useMemo, useCallback } from "react"; +import { KeyboardLayout, KeyLegends, KeyLayer } from "./types/schema"; +import { keys } from "@/keyboardMappings"; +import { Keycap } from "./Keycap"; + +export interface VirtualKeyboardProps { + /** KeyboardLayout received from Go backend via JSON-RPC. */ + keyboard: KeyboardLayout; + + /** Whether the META key is currently held. */ + isMetaActive: boolean; + + /** Called when the user presses a virtual key. */ + onKeySend: (scancode: number) => void; + + /** + * Set of currently pressed scancodes (from keysDownState). + * Used for visual pressed highlights AND layer derivation. + * The parent is responsible for populating this from the HID store. + */ + pressedScancodes?: ReadonlySet; + + /** Extra CSS classes to add to the .vkb container (e.g. LED state classes). */ + vkbClassName?: string; + + /** True when host keyboard Kana LED is on. */ + kanaLedOn?: boolean; +} + +export function VirtualKeyboard({ + keyboard, + isMetaActive: _isMetaActive, + onKeySend, + pressedScancodes, + vkbClassName, + kanaLedOn = false, +}: VirtualKeyboardProps) { + // Derive display layer purely from pressedScancodes + const layer: KeyLayer = useMemo(() => { + const hasShift = + pressedScancodes?.has(keys.ShiftLeft) || pressedScancodes?.has(keys.ShiftRight); + const hasAltgr = pressedScancodes?.has(keys.AltRight); + + if (kanaLedOn) { + return hasShift ? "shift-kana" : "kana"; + } + + if (hasShift && hasAltgr) return "shift-altgr"; + if (hasShift) return "shift"; + if (hasAltgr) return "altgr"; + return "all"; + }, [pressedScancodes, kanaLedOn]); + + const handleKeyPress = useCallback( + (scancode: number, _legends: KeyLegends) => { + onKeySend(scancode); + }, + [onKeySend], + ); + + return ( +
+
e.preventDefault()} + > + {keyboard.keys.map((key, i) => ( + + ))} +
+
+ ); +} diff --git a/ui/src/components/keyboard/types/schema.ts b/ui/src/components/keyboard/types/schema.ts new file mode 100644 index 000000000..d5506b606 --- /dev/null +++ b/ui/src/components/keyboard/types/schema.ts @@ -0,0 +1,245 @@ +/** + * transport/schema.ts + * + * TypeScript definitions for the KeyboardLayout transport type. + * + * These types define what the Go backend sends and the React client receives. + * The client does NO parsing and NO scancode inference — it only renders + * and dispatches scancodes from this pre-processed structure. + * + * ⚠️ JSON field names here are the wire contract with the Go backend. + * Do not rename fields without updating go/keyboard.go simultaneously. + * + * All keyboard types (transport and client-side) live in this file. + */ + +// --------------------------------------------------------------------------- +// Active layer state (client-only, not part of transport) +// --------------------------------------------------------------------------- + +/** + * The active display layer of the virtual keyboard. + * - 'normal' : base layer (no modifiers) + * - 'shift' : Shift held + * - 'altgr' : AltGr (Right Alt) held + * - 'shift-altgr' : Shift + AltGr held + * - 'kana' : Kana LED on + * - 'shift-kana' : Kana LED on + Shift held + * - 'all' : preview mode — shows all layers simultaneously in quadrants + * + * This value is set as data-layer="..." on the .vkb container. + * All legend show/hide logic is handled entirely by CSS selectors on this attribute. + */ +export type KeyLayer = "normal" | "shift" | "altgr" | "shift-altgr" | "kana" | "shift-kana" | "all"; + +// --------------------------------------------------------------------------- +// HID modifier constants +// --------------------------------------------------------------------------- + +export const MOD_NONE: number = 0x00; +export const MOD_LCTRL: number = 0x01; +export const MOD_LSHIFT: number = 0x02; +export const MOD_LALT: number = 0x04; +export const MOD_LMETA: number = 0x08; +export const MOD_RCTRL: number = 0x10; +export const MOD_RSHIFT: number = 0x20; +export const MOD_RALT: number = 0x40; +export const MOD_RMETA: number = 0x80; + +export const MOD_ALTGR: number = MOD_RALT; // Right Alt +export const MOD_SHIFT_ALTGR: number = MOD_LSHIFT | MOD_ALTGR; // Shift + AltGr + +// --------------------------------------------------------------------------- +// HIDCombo — a single key event to send over USB HID +// --------------------------------------------------------------------------- + +/** + * A USB HID key event: scancode + modifier byte. + * Short field names because charMap can have 200+ entries and is sent + * on every session connect. + */ +export interface HIDCombo { + /** USB HID Usage ID (page 0x07), e.g. 0x04 = A, 0x1E = 1 */ + s: number; + /** Modifier byte: 0=none, 0x02=Shift, 0x40=AltGr, 0x42=Shift+AltGr */ + m: number; + /** Dead key prefix — if present, send this HID event first, then the main key. + * Used for composed characters (e.g. ^ then a → â) and standalone dead keys + * (e.g. ^ then Space → ^). */ + p?: HIDCombo; +} + +// --------------------------------------------------------------------------- +// Legend data +// --------------------------------------------------------------------------- + +/** + * The four legend slots on a keycap, corresponding to modifier combinations. + * Derived from the KLE legend string by splitting on '\n' and reading positions. + * + * KLE legend position mapping (default alignment a=4): + * position 0 → normal (unshifted) + * position 1 → shift (Shift) + * position 2 → altgr (AltGr / Right Alt) + * position 3 → shiftAltgr (Shift + AltGr) + * + * Example KLE string "1\n!\n²\n¹" produces: + * { normal: '1', shift: '!', altgr: '²', shiftAltgr: '¹' } + */ +export interface KeyLegends { + normal?: string; + shift?: string; + altgr?: string; + shiftAltgr?: string; + kana?: string; + shiftKana?: string; +} + +// --------------------------------------------------------------------------- +// Key shape +// --------------------------------------------------------------------------- + +/** + * Pre-computed shape class for the keycap div. + * Computed by Go from the KLE w/h/w2/h2 signature. + * Applied directly as a CSS class — no client-side shape detection needed. + */ +export type KeyShape = + | "" // standard rectangle + | "iso-enter" + | "big-ass-enter" + | "stepped-caps"; + +// --------------------------------------------------------------------------- +// TransportKey — a single keycap, fully resolved +// --------------------------------------------------------------------------- + +/** + * A single keycap ready for rendering and HID dispatch. + * All derived values (shape, scancode, dead) are pre-computed by Go. + */ +export interface TransportKey { + // --- Position and size (in keyboard units) --- + x: number; + y: number; + w: number; // width, default 1 + h: number; // height, default 1 + + // Second rectangle for L-shaped keys (ISO enter, stepped caps). + // Omitted entirely for standard rectangular keys. + w2?: number; + h2?: number; + x2?: number; + y2?: number; + + // --- Pre-computed by Go --- + + /** CSS class to apply to the keycap div. Empty string for normal keys. */ + shape: KeyShape; + + /** The four legend layers. Fields omitted when no legend for that layer. */ + legends: KeyLegends; + + /** + * USB HID Usage ID for this key's physical position. + * 0 = non-typeable key (modifier-only, unknown). + * This is the value sent in the HID report when the virtual key is pressed. + */ + scancode: number; + + /** + * Which specific legend slots on this key are dead key characters. + * Values are legend class names: 'normal', 'shift', 'altgr', 'shift-altgr', 'kana', 'shift-kana'. + * Empty if no legends on this key are dead key characters. + */ + deadLegends?: string[]; + + /** Homing key — has a tactile bump or bar (typically F and J keys). */ + homing: boolean; + + /** Decal — a label printed on the keyboard, not a physical keycap. */ + decal: boolean; + + /** ControlLike - key classification (Esc, Enter, arrows, Shift…). */ + controlLike: boolean; + + // --- Optional KLE colorway (only present when KLE file specifies colors) --- + color?: string; // CSS color string, e.g. "#2d2d2d" + textColor?: string; // CSS color string, e.g. "#e0e0e0" +} + +// --------------------------------------------------------------------------- +// KeyboardLayout — the full transport type +// --------------------------------------------------------------------------- + +/** + * A fully processed keyboard layout, ready to pass to . + * + * Produced by Go on upload, stored as JSON, served via JSON-RPC. + * The React client treats this as opaque render data. + */ +export interface KeyboardLayout { + /** Unique identifier. Built-in layouts use locale codes (e.g. "de_DE"). + * User-uploaded layouts use UUIDs. */ + id: string; + + /** Display name for settings UI (from KLE meta.name or user-provided). */ + name: string; + + /** Author (from KLE meta.author). Optional. */ + author?: string; + + /** Total board width in keyboard units. Used for --board-w CSS variable. */ + boardW: number; + + /** Total board height in keyboard units. Used for --board-h CSS variable. */ + boardH: number; + + /** All keycaps in the layout. Order is row-major (top-left to bottom-right). */ + keys: TransportKey[]; + + /** + * Character-to-HID map for the Paste Text system. + * + * Maps a Unicode character (single codepoint) to the HID scancode + + * modifier combination that produces it on the target machine. + * + * Built by Go from the key legends. First occurrence of each character wins. + * + * TODO! + * Dead key compositions (e.g. ^+e → ê) are NOT in this map — they require + * the optional dead key sequence list (future work). + * + * Example: + * { "a": {"s": 4, "m": 0}, "A": {"s": 4, "m": 2}, "@": {"s": 31, "m": 64} } + */ + charMap: Record; +} + +// --------------------------------------------------------------------------- +// LayoutMeta — lightweight listing type for the settings dropdown +// --------------------------------------------------------------------------- + +/** + * Returned by rpcGetKeyboardLayouts(). + * Does NOT include keys or charMap — just enough to populate a picker. + */ +export interface LayoutMeta { + id: string; + name: string; + builtin: boolean; +} + +// --------------------------------------------------------------------------- +// Upload response +// --------------------------------------------------------------------------- + +/** + * Returned by POST /keyboard/upload on success. + */ +export interface LayoutUploadResponse { + id: string; // UUID assigned to the new layout + name: string; // display name (from KLE meta or ?name param) + keyCount: number; // number of keys parsed (for user confirmation) + warnings?: string[]; // non-fatal issues found during parsing +} diff --git a/ui/src/components/keyboard/useKleUpload.ts b/ui/src/components/keyboard/useKleUpload.ts new file mode 100644 index 000000000..5fd4e5f6f --- /dev/null +++ b/ui/src/components/keyboard/useKleUpload.ts @@ -0,0 +1,98 @@ +/** + * Handles KLE JSON upload to the Go backend HTTP endpoint. + * The backend parses KLE, infers scancodes, builds charMap, validates, + * stores to userdata, and returns a LayoutUploadResponse. + * + * Supports two input paths: + * - openFilePicker(): native file input (.json) + * - uploadFromString(json): raw KLE JSON string (from KLE "Raw data" tab) + * + * Both POST to /keyboard/upload. Pass an optional `id` to replace an + * existing user-uploaded layout instead of creating a new one. + */ + +import { useState, useRef, useCallback } from 'react'; +import { LayoutUploadResponse } from './types/schema'; + +export interface KleUploadState { + result: LayoutUploadResponse | null; + isUploading: boolean; + error: string | null; + openFilePicker: (replaceId?: string) => void; + uploadFromString: (json: string, name?: string, replaceId?: string) => Promise; + clear: () => void; +} + +const UPLOAD_ENDPOINT = '/keyboard/upload'; +const MAX_FILE_SIZE = 512 * 1024; + +export function useKleUpload(): KleUploadState { + const [result, setResult] = useState(null); + const [isUploading, setIsUploading] = useState(false); + const [error, setError] = useState(null); + const inputRef = useRef(null); + + const uploadRaw = useCallback(async (jsonText: string, name?: string, replaceId?: string) => { + setIsUploading(true); + setError(null); + setResult(null); + try { + const params = new URLSearchParams(); + if (name) params.set('name', name); + if (replaceId) params.set('id', replaceId); + const qs = params.toString(); + const url = qs ? `${UPLOAD_ENDPOINT}?${qs}` : UPLOAD_ENDPOINT; + + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: jsonText, + }); + if (!response.ok) { + const body = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(body.error ?? `Upload failed: ${response.status}`); + } + setResult(await response.json() as LayoutUploadResponse); + } catch (e) { + setError((e as Error).message); + } finally { + setIsUploading(false); + } + }, []); + + const ensureInput = useCallback(() => { + if (inputRef.current) return inputRef.current; + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json,application/json'; + input.style.display = 'none'; + document.body.appendChild(input); + inputRef.current = input; + return input; + }, []); + + const openFilePicker = useCallback((replaceId?: string) => { + const input = ensureInput(); + input.onchange = async (e: Event) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + input.value = ''; + if (file.size > MAX_FILE_SIZE) { + setError(`File too large (${(file.size / 1024).toFixed(0)} KB).`); + return; + } + await uploadRaw(await file.text(), file.name.replace(/\.json$/i, ''), replaceId); + }; + input.click(); + }, [ensureInput, uploadRaw]); + + const uploadFromString = useCallback(async (json: string, name?: string, replaceId?: string) => { + await uploadRaw(json, name, replaceId); + }, [uploadRaw]); + + const clear = useCallback(() => { + setResult(null); setError(null); setIsUploading(false); + }, []); + + return { result, isUploading, error, openFilePicker, uploadFromString, clear }; +} diff --git a/ui/src/components/keyboard/virtual-keyboard.css b/ui/src/components/keyboard/virtual-keyboard.css new file mode 100644 index 000000000..d986ba0ab --- /dev/null +++ b/ui/src/components/keyboard/virtual-keyboard.css @@ -0,0 +1,424 @@ +/* + * All styles for the virtual keyboard component. + * + * Design principles: + * - Layer switching is PURE CSS via data-layer attribute on .vkb + * - Entire board scales by changing --u (one keyboard unit) + * - Key shapes (ISO enter, stepped caps) use clip-path or named classes + * - KLE per-key colors applied via --key-color CSS variable set inline + * + * All rules are scoped under .vkb-wrapper or .vkb to avoid collisions + * with other components (e.g. .key, .legend are generic class names). + * + * CSS custom properties used by the component: + * --u 1 keyboard unit in px/rem (set on .vkb, default 3.5rem) + * --gap gap between keys (default 0.2rem) + * --board-w total board width in units (set inline from KeyboardLayout) + * --board-h total board height in units (set inline from KeyboardLayout) + * --kx key x position in units (set inline per key) + * --ky key y position in units (set inline per key) + * --kh key height in units (set inline, default 1) + * --key-color keycap background color (from KLE 'c' property or default) + * --key-w for non-standard widths with class 'w-custom' + */ + +/* =========================================================================== + WRAPPER + =========================================================================== */ + +.vkb-wrapper { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + padding: 0.75rem; + background: var(--vkb-bg, #1a1a1a); + border-radius: 0.5rem; + user-select: none; + container-type: inline-size; + width: 100%; +} + +/* =========================================================================== + BOARD + =========================================================================== */ + +.vkb { + /* + * --u is 1 keyboard unit, auto-scaled to fill the container width. + * 100cqi = full container width. Dividing by --board-w gives the + * per-unit size that makes the keyboard exactly fill its container. + * Font sizes scale with --u since keys scale with --u. + */ + --u: calc(100cqi / var(--board-w)); + --pad: 1px; /* visual gap between keys (inset on each side) */ + + position: relative; + width: 100%; + height: calc(var(--board-h) * var(--u)); +} + +/* =========================================================================== + KEYCAP BASE + All .key rules are scoped under .vkb to avoid collisions. + =========================================================================== */ +.vkb .key { + --key-color: #2d2d2d; + --key-text-color: #e0e0e0; + --key-border: color-mix(in srgb, var(--key-color) 60%, black); + --key-shadow: color-mix(in srgb, var(--key-color) 40%, black); + + position: absolute; + left: calc(var(--kx) * var(--u) + var(--pad)); + top: calc(var(--ky) * var(--u) + var(--pad)); + width: calc(1 * var(--u) - var(--pad) * 2); + height: calc(var(--kh, 1) * var(--u) - var(--pad) * 2); + + background: var(--key-color); + border-radius: 0.25rem; + + /* Physical key depth effect */ + border-bottom: 3px solid var(--key-border); + box-shadow: 0 2px 4px var(--key-shadow); + + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + + color: var(--key-text-color); + font-family: inherit; + font-size: calc(var(--u) * 0.3); + line-height: 1; + overflow: hidden; + + transition: + transform 60ms ease, + border-bottom-width 60ms ease, + box-shadow 60ms ease; +} + +.vkb .key:hover { + background: color-mix(in srgb, var(--key-color) 85%, white); +} + +/* Press animation */ +.vkb .key:active, +.vkb .key.pressed { + transform: translateY(2px); + border-bottom-width: 1px; + box-shadow: 0 0px 2px var(--key-shadow); + background: color-mix(in srgb, var(--key-color) 80%, #4a9eff); +} + +/* =========================================================================== + KEYCAP WIDTH CLASSES + =========================================================================== */ +/* Generated from standard layout widths. Subtract padding for visual gap. */ +.vkb .key.w-125 { + width: calc(1.25 * var(--u) - var(--pad) * 2); +} +.vkb .key.w-150 { + width: calc(1.5 * var(--u) - var(--pad) * 2); +} +.vkb .key.w-175 { + width: calc(1.75 * var(--u) - var(--pad) * 2); +} +.vkb .key.w-200 { + width: calc(2 * var(--u) - var(--pad) * 2); +} +.vkb .key.w-225 { + width: calc(2.25 * var(--u) - var(--pad) * 2); +} +.vkb .key.w-250 { + width: calc(2.5 * var(--u) - var(--pad) * 2); +} +.vkb .key.w-275 { + width: calc(2.75 * var(--u) - var(--pad) * 2); +} +.vkb .key.w-300 { + width: calc(3 * var(--u) - var(--pad) * 2); +} +.vkb .key.w-350 { + width: calc(3.5 * var(--u) - var(--pad) * 2); +} +.vkb .key.w-400 { + width: calc(4 * var(--u) - var(--pad) * 2); +} +.vkb .key.w-600 { + width: calc(6 * var(--u) - var(--pad) * 2); +} +.vkb .key.w-625 { + width: calc(6.25 * var(--u) - var(--pad) * 2); +} +.vkb .key.w-700 { + width: calc(7 * var(--u) - var(--pad) * 2); +} +/* Non-standard width fallback — --key-w set inline by getCustomWidthStyle() */ +.vkb .key.w-custom { + width: calc(var(--key-w) * var(--u) - var(--pad) * 2); +} + +/* =========================================================================== + KEYCAP SHAPE CLASSES + =========================================================================== */ +/* + * ISO Enter — L-shaped key. + * KLE encodes this as two overlapping rectangles: + * rect1: w=1.25, h=2 (the narrow bottom part) + * rect2: x2=-0.25, w2=1.5, h2=1 (the wide top part) + * + * We approximate the L-shape with clip-path. + * The notch cuts out the bottom-right corner. + * Exact proportions: top section is 1.5u wide, bottom section is 1.25u wide, + * so the notch is 0.25u = (0.25/1.5) = 16.7% of the width. + * + * clip-path coordinates are percentages of the element's bounding box. + */ +.vkb .key.iso-enter { + width: calc(1.5 * var(--u) - var(--pad) * 2); + height: calc(2 * var(--u) - var(--pad) * 2); + + /* + * ISO Enter L-shape: + * - Top part: full 1.5u width + * - Bottom part: 1.25u, right-aligned (notch cuts bottom-left) + * - Notch width: (1.5-1.25)/1.5 = 16.7% from left + * - 1% inset at the midpoint creates a visible gap with the key below + */ + clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 16.7% 100%, 16.7% 46%, 0% 46%); +} + +/* + * Big-ass Enter — inverted L-shape (wide bottom, narrow top). + * KLE encodes: w=1.5 (top), w2=2.25 (bottom), x2=-0.75, y2=1. + * Rendered at the wider w2 width. Clip cuts the top-left corner. + * Notch: (w2-w)/w2 = (2.25-1.5)/2.25 = 33.3% from left. + */ +.vkb .key.big-ass-enter { + width: calc(2.25 * var(--u) - var(--pad) * 2); + height: calc(2 * var(--u) - var(--pad) * 2); + clip-path: polygon(33.3% 0%, 100% 0%, 100% 100%, 0% 100%, 0% 54%, 33.3% 54%); +} + +/* ISO/big-ass Enter: in "all" mode, keep legend in the top half (visible area) */ +.vkb[data-layer="all"] .key.iso-enter .legend, +.vkb[data-layer="all"] .key.big-ass-enter .legend { + top: 10%; + bottom: 50%; + left: 0; + right: 0; + align-items: center; + justify-content: center; +} + +/* Stepped Caps Lock */ +.vkb .key.stepped-caps { + /* Visual step: slightly lighter right side to indicate stepped profile */ + background-image: linear-gradient( + 90deg, + var(--key-color) 75%, + color-mix(in srgb, var(--key-color) 85%, black) 75% + ); +} + +/* =========================================================================== + LEGENDS — base state (all hidden) + =========================================================================== */ +.vkb .key .legend { + position: absolute; + display: flex; + pointer-events: none; + font-size: calc(var(--u) * 0.3); + line-height: 1; + font-variant-numeric: tabular-nums; +} + +/* Quadrant positions for keys legends. */ +.vkb .key .legend.normal { + bottom: 3px; + left: 4px; + align-items: flex-end; +} +.vkb .key .legend.shift { + top: 3px; + left: 4px; + align-items: flex-start; +} +.vkb .key .legend.altgr { + bottom: 3px; + right: 4px; + align-items: flex-end; +} +.vkb .key .legend.shift-altgr { + top: 3px; + right: 4px; + align-items: flex-start; +} + +/* Decal — a label on the keyboard housing, not a physical keycap. */ +.vkb .key.decal { + background: transparent; + border: none; + box-shadow: none; + cursor: default; + pointer-events: none; + color: rgba(255, 255, 255, 0.25); + font-size: calc(var(--u) * 0.25); +} + +.vkb .key.decal .legend { + display: flex; +} + +/* =========================================================================== + LEGEND VISIBILITY — "all" mode (no other layer active) + All legends shown simultaneously in quadrant positions. + =========================================================================== */ +.vkb[data-layer="all"] .key:not(.decal) .legend { + display: flex; + align-items: flex-end; + font-size: calc(var(--u) * 0.3); +} + +/* in all mode, if the shift legend is the same as normal, hide the +shift legend to avoid redundancy */ +.vkb[data-layer="all"] .key:not(.decal):has(.legend.normal.nds) .legend.shift.nds { + display: none; +} + +/* =========================================================================== + LEGEND VISIBILITY — single layer mode + Legend is centered within the key when only one layer is active. + =========================================================================== */ +/* + On a modifier active layer, make sure other layer legends are shown + at reduced opacity and smaller font size to indicate they are + reachable by other modifiers + */ +.vkb[data-layer="shift"] .key:not(.decal) .legend:not(.shift), +.vkb[data-layer="altgr"] .key:not(.decal) .legend:not(.altgr), +.vkb[data-layer="shift-altgr"] .key:not(.decal) .legend:not(.shift-altgr), +.vkb[data-layer="kana"] .key:not(.decal) .legend:not(.kana), +.vkb[data-layer="shift-kana"] .key:not(.decal) .legend:not(.shift-kana) { + opacity: 0.5; + font-size: calc(var(--u) * 0.2); +} + +.vkb[data-layer="shift"] .key:not(.decal) .legend.shift, +.vkb[data-layer="altgr"] .key:not(.decal) .legend.altgr, +.vkb[data-layer="shift-altgr"] .key:not(.decal) .legend.shift-altgr, +.vkb[data-layer="kana"] .key:not(.decal) .legend.kana, +.vkb[data-layer="shift-kana"] .key:not(.decal) .legend.shift-kana, +.vkb[data-layer="all"] .key:not(.decal) .legend.nds, +.vkb .key:not(.decal):has(.legend:only-child) .legend { + display: flex; + inset: 0; + align-items: center; + justify-content: center; + text-align: center; + font-size: calc(var(--u) * 0.3); +} + +/* + Fallback: when no legend exists for the active layer, show the normal legend + to indicate "no change in this layer" with slightly reduced opacity. + */ +.vkb[data-layer="shift"] .key:not(.decal):not(:has(.legend.shift)) .legend.normal, +.vkb[data-layer="altgr"] .key:not(.decal):not(:has(.legend.altgr)) .legend.normal, +.vkb[data-layer="shift-altgr"] .key:not(.decal):not(:has(.legend.shift-altgr)) .legend.normal, +.vkb[data-layer="kana"] .key:not(.decal):not(:has(.legend.kana)) .legend.normal, +.vkb[data-layer="shift-kana"] .key:not(.decal):not(:has(.legend.shift-kana)) .legend.normal { + display: flex; + inset: 0; + align-items: center; + justify-content: center; + opacity: 0.9; + text-align: center; + font-size: calc(var(--u) * 0.3); +} + +/* =========================================================================== + LEGEND VISIBILITY — CapsLock + When CapsLock is on: letter keys show shift legend, non-letters stay normal. + When CapsLock + Shift: letters revert to normal, non-letters show shift. + Behavior is consistent across Windows, macOS, and Linux. + ============================================================================ */ +/* CapsLock on, idle ("all" mode): letter keys swap quadrants positions normal→shift */ +.vkb.caps-lock-on[data-layer="all"] .key.letter .legend.normal { + /* Take the shift legend's quadrant position (top-left) */ + top: 3px; + bottom: auto; + align-items: flex-start; +} +.vkb.caps-lock-on[data-layer="all"] .key.letter .legend.shift { + /* Take the normal legend's quadrant position (bottom-left) */ + bottom: 3px; + top: auto; + align-items: flex-end; +} + +/* CapsLock on + Shift: letters revert to normal (Shift cancels CapsLock) */ +.vkb.caps-lock-on[data-layer="shift"] .key.letter .legend.normal { + bottom: 3px; + top: auto; + align-items: flex-end; +} +.vkb.caps-lock-on[data-layer="shift"] .key.letter .legend.shift { + top: 3px; + bottom: auto; + align-items: flex-start; +} + +/* ================================================================================= + HOMING KEY INDICATORS + Homing keys (F, J, numpad 5) — subtle bar indicator like the physical tactile bump + ================================================================================== */ +.vkb .key.homing::after { + content: ""; + position: absolute; + bottom: 4px; + left: 50%; + transform: translateX(-50%); + width: 35%; + height: 1.5px; + background: color-mix(in srgb, var(--key-text-color) 40%, transparent); + border-radius: 1px; + pointer-events: none; +} + +/* =========================================================================== + LOCK KEY INDICATORS + Lock key LED indicators + CapsLock=57 (0x39), ScrollLock=71 (0x47), NumLock=83 (0x53), Kana=136 (0x88) + ============================================================================ */ +.vkb.caps-lock-on .key[data-scancode="57"]::before, +.vkb.scroll-lock-on .key[data-scancode="71"]::before, +.vkb.num-lock-on .key[data-scancode="83"]::before, +.vkb.kana-on .key[data-scancode="136"]::before { + content: ""; + position: absolute; + top: 3px; + right: 3px; + width: 5px; + height: 5px; + border-radius: 50%; + background: #4ade80; + box-shadow: 0 0 4px rgba(74, 222, 128, 0.6); + pointer-events: none; + z-index: 1; +} + +/* =========================================================================== + DEAD KEY INDICATOR + Keys with dead key legends get a small orange dot suffix. + Applied via ::after on the visible legend span with the dead class. + =========================================================================== */ +.vkb .key .legend.dead::after { + content: "●"; + font-size: 0.25em; + vertical-align: super; + color: #f6ad55; + margin-left: 2px; + text-shadow: 0 0 3px rgba(246, 173, 85, 0.6); +} diff --git a/ui/src/components/popovers/PasteModal.tsx b/ui/src/components/popovers/PasteModal.tsx index 39b5e582b..c543fd806 100644 --- a/ui/src/components/popovers/PasteModal.tsx +++ b/ui/src/components/popovers/PasteModal.tsx @@ -7,8 +7,10 @@ import { cx } from "@/cva.config"; import { m } from "@localizations/messages.js"; import { useHidStore, useSettingsStore, useUiStore } from "@hooks/stores"; import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc"; -import useKeyboard, { type MacroStep } from "@hooks/useKeyboard"; -import useKeyboardLayout from "@hooks/useKeyboardLayout"; +import useKeyboard from "@hooks/useKeyboard"; +import { KeyboardMacroStep } from "@/hooks/hidRpc"; +import { hidKeyBufferSize } from "@/hooks/stores"; +import type { KeyboardLayout } from "@components/keyboard/types/schema"; import notifications from "@/notifications"; import { Button } from "@components/Button"; import { GridCard } from "@components/Card"; @@ -20,6 +22,12 @@ import { TextAreaWithLabel } from "@components/TextArea"; const pasteMaxLength = 1073741824; const defaultDelay = 20; +const MACRO_RESET: KeyboardMacroStep = { + keys: new Array(hidKeyBufferSize).fill(0), + modifier: 0, + delay: 0, +}; + export default function PasteModal() { const TextAreaRef = useRef(null); const { isPasteInProgress } = useHidStore(); @@ -28,7 +36,7 @@ export default function PasteModal() { const { setDisableVideoFocusTrap } = useUiStore(); const { send } = useJsonRpc(); - const { executeMacro, cancelExecuteMacro } = useKeyboard(); + const { executeHidMacro, cancelExecuteMacro } = useKeyboard(); const [invalidChars, setInvalidChars] = useState([]); const [delayValue, setDelayValue] = useState(defaultDelay); @@ -43,15 +51,20 @@ export default function PasteModal() { const debugMode = useSettingsStore(state => state.debugMode); const delayClassName = useMemo(() => (debugMode ? "" : "hidden"), [debugMode]); - const { setKeyboardLayout } = useSettingsStore(); - const { selectedKeyboard } = useKeyboardLayout(); + // Fetch the KLE layout from the backend + const { keyboardLayout } = useSettingsStore(); + const [kleLayout, setKleLayout] = useState(null); useEffect(() => { - void send("getKeyboardLayout", {}, (resp: JsonRpcResponse) => { - if ("error" in resp) return; - setKeyboardLayout(resp.result as string); + if (!keyboardLayout) return; + void send("getKeyboardLayoutData", { id: keyboardLayout }, (resp: JsonRpcResponse) => { + if ("error" in resp) { + setKleLayout(null); + return; + } + setKleLayout(resp.result as KeyboardLayout); }); - }, [send, setKeyboardLayout]); + }, [send, keyboardLayout]); const onCancelPasteMode = useCallback(() => { void cancelExecuteMacro(); @@ -61,71 +74,51 @@ export default function PasteModal() { const updateInvalidChars = useCallback( (value: string) => { + if (!kleLayout) return; const chars = [ ...new Set( [...(new Intl.Segmenter().segment(value) ?? [])] .map(x => x.segment.normalize("NFC")) - .filter(char => !selectedKeyboard?.chars[char]), + .filter(char => !kleLayout.charMap[char]), ), ]; setInvalidChars(chars); }, - [selectedKeyboard], + [kleLayout], ); const onConfirmPaste = useCallback(async () => { - if (!selectedKeyboard) return; + if (!kleLayout) return; const text = textValue; try { - const macroSteps: MacroStep[] = []; - - for (const char of text) { - const normalizedChar = char.normalize("NFC"); - const keyprops = selectedKeyboard.chars[normalizedChar]; - if (!keyprops) continue; + const macro: KeyboardMacroStep[] = []; - const { key, shift, altRight, deadKey, accentKey } = keyprops; - if (!key) continue; + for (const { segment } of new Intl.Segmenter().segment(text)) { + const normalizedChar = segment.normalize("NFC"); + const combo = kleLayout.charMap[normalizedChar]; + if (!combo || combo.s === 0) continue; - // if this is an accented character, we need to send that accent FIRST - if (accentKey) { - const accentModifiers: string[] = []; - if (accentKey.shift) accentModifiers.push("ShiftLeft"); - if (accentKey.altRight) accentModifiers.push("AltRight"); - - macroSteps.push({ - keys: [String(accentKey.key)], - modifiers: accentModifiers.length > 0 ? accentModifiers : null, - delay, - }); + // Dead key prefix: send the dead key first, release, then the base key + if (combo.p) { + macro.push({ keys: [combo.p.s], modifier: combo.p.m, delay: 20 }); + macro.push({ ...MACRO_RESET, delay }); } - // now send the actual key - const modifiers: string[] = []; - if (shift) modifiers.push("ShiftLeft"); - if (altRight) modifiers.push("AltRight"); - - macroSteps.push({ - keys: [String(key)], - modifiers: modifiers.length > 0 ? modifiers : null, - delay, - }); - - // if what was requested was a dead key, we need to send an unmodified space to emit - // just the accent character - if (deadKey) macroSteps.push({ keys: ["Space"], modifiers: null, delay }); + // Press the key with its modifiers, then release + macro.push({ keys: [combo.s], modifier: combo.m, delay: 20 }); + macro.push({ ...MACRO_RESET, delay }); } - if (macroSteps.length > 0) { - await executeMacro(macroSteps); + if (macro.length > 0) { + await executeHidMacro(macro); } } catch (error) { console.error("Failed to paste text:", error); notifications.error(m.paste_modal_failed_paste({ error: String(error) })); } - }, [selectedKeyboard, executeMacro, delay, textValue]); + }, [kleLayout, executeHidMacro, delay, textValue]); useEffect(() => { TextAreaRef.current?.focus(); @@ -221,29 +214,33 @@ export default function PasteModal() { type="number" label={m.paste_modal_delay_between_keys()} placeholder={m.paste_modal_delay_between_keys()} - min={50} + min={20} max={65534} value={delayValue} onChange={e => { setDelayValue(parseInt(e.target.value, 10)); }} /> - {delayValue < 50 || - (delayValue > 65534 && ( -
- - - {m.paste_modal_delay_out_of_range({ min: 50, max: 65534 })} - -
- ))} + {(delayValue < 20 || delayValue > 65534) && ( +
+ + + {m.paste_modal_delay_out_of_range({ min: 20, max: 65534 })} + +
+ )}

- {m.paste_modal_sending_using_layout({ - iso: selectedKeyboard.isoCode, - name: selectedKeyboard.name, - })} + {kleLayout + ? m.paste_modal_sending_using_layout({ + iso: kleLayout.id, + name: kleLayout.name, + }) + : m.paste_modal_sending_using_layout({ + iso: keyboardLayout ?? "", + name: keyboardLayout ?? "", + })}

@@ -261,6 +258,7 @@ export default function PasteModal() { size="SM" theme="blank" text={m.cancel()} + data-testid="paste-modal-cancel" onClick={() => { onCancelPasteMode(); close(); @@ -270,7 +268,8 @@ export default function PasteModal() { size="SM" theme="primary" text={m.paste_modal_confirm_paste()} - disabled={isPasteInProgress} + data-testid="paste-modal-confirm-paste" + disabled={isPasteInProgress || !kleLayout} onClick={onConfirmPaste} LeadingIcon={LuCornerDownLeft} /> diff --git a/ui/src/components/textToMacroSteps.ts b/ui/src/components/textToMacroSteps.ts new file mode 100644 index 000000000..8564df7b7 --- /dev/null +++ b/ui/src/components/textToMacroSteps.ts @@ -0,0 +1,94 @@ +/** + * Converts a text string into macro steps using the KLE layout's charMap. + * + * Each character is looked up in the charMap to get its HID scancode + modifier byte, + * then reverse-mapped to the key/modifier names used by the macro system. + * + * Returns { steps, invalidChars } — steps ready to append to a macro, + * and any characters that couldn't be mapped. + */ + +import { keys, modifiers } from "@/keyboardMappings"; +import type { KeyboardLayout, HIDCombo } from "@components/keyboard/types/schema"; +import type { KeySequenceStep } from "@hooks/stores"; + +const DEFAULT_DELAY = 50; + +// Build reverse lookup: scancode → key name (first match wins) +export const scancodeToKeyName = new Map(); +for (const [name, scancode] of Object.entries(keys)) { + // Skip modifier key names (they go in the modifiers array, not keys) + if (name.startsWith("Control") || name.startsWith("Shift") || + name.startsWith("Alt") || name.startsWith("Meta")) { + continue; + } + if (!scancodeToKeyName.has(scancode)) { + scancodeToKeyName.set(scancode, name); + } +} + +// Modifier bit → modifier name +const modBitToName: [number, string][] = [ + [modifiers.ShiftLeft, "ShiftLeft"], + [modifiers.ShiftRight, "ShiftRight"], + [modifiers.ControlLeft, "ControlLeft"], + [modifiers.ControlRight, "ControlRight"], + [modifiers.AltLeft, "AltLeft"], + [modifiers.AltRight, "AltRight"], + [modifiers.MetaLeft, "MetaLeft"], + [modifiers.MetaRight, "MetaRight"], +]; + +function comboToStep(combo: HIDCombo): KeySequenceStep | null { + const keyName = scancodeToKeyName.get(combo.s); + if (!keyName) return null; + + const mods: string[] = []; + for (const [bit, name] of modBitToName) { + if (combo.m & bit) { + mods.push(name); + } + } + + return { keys: [keyName], modifiers: mods, delay: DEFAULT_DELAY }; +} + +export interface TextToMacroResult { + steps: KeySequenceStep[]; + invalidChars: string[]; +} + +export function textToMacroSteps( + text: string, + layout: KeyboardLayout, +): TextToMacroResult { + const steps: KeySequenceStep[] = []; + const invalidChars: string[] = []; + + for (const char of text) { + const normalizedChar = char.normalize("NFC"); + const combo = layout.charMap[normalizedChar]; + + if (!combo || combo.s === 0) { + if (!invalidChars.includes(normalizedChar)) { + invalidChars.push(normalizedChar); + } + continue; + } + + // Dead key prefix: add the prefix keystroke first + if (combo.p) { + const prefixStep = comboToStep(combo.p); + if (prefixStep) { + steps.push(prefixStep); + } + } + + const step = comboToStep(combo); + if (step) { + steps.push(step); + } + } + + return { steps, invalidChars }; +} diff --git a/ui/src/hooks/hidRpc.ts b/ui/src/hooks/hidRpc.ts index a6b08b90f..e0cb936ab 100644 --- a/ui/src/hooks/hidRpc.ts +++ b/ui/src/hooks/hidRpc.ts @@ -240,7 +240,7 @@ export class KeyboardMacroReportMessage extends RpcMessage { // Ensure the keys are within the KEYS_LENGTH range const keys = step.keys; if (keys.length > this.KEYS_LENGTH) { - throw new Error(`Keys ${keys} is not within the hidKeyBufferSize range`); + throw new Error(`Keys [${keys.join(", ")}] is not within the hidKeyBufferSize range`); } else if (keys.length < this.KEYS_LENGTH) { keys.push(...Array(this.KEYS_LENGTH - keys.length).fill(0)); } diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index 23399f6ec..018cfe57a 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -385,6 +385,9 @@ export interface SettingsState { showPressedKeys: boolean; setShowPressedKeys: (show: boolean) => void; + modifierLatching: boolean; + setModifierLatching: (value: boolean) => void; + // Video enhancement settings videoSaturation: number; setVideoSaturation: (value: number) => void; @@ -440,6 +443,9 @@ export const useSettingsStore = create( showPressedKeys: true, setShowPressedKeys: (show: boolean) => set({ showPressedKeys: show }), + modifierLatching: true, + setModifierLatching: (value: boolean) => set({ modifierLatching: value }), + // Video enhancement settings with default values (1.0 = normal) videoSaturation: 1.0, setVideoSaturation: (value: number) => set({ videoSaturation: value }), diff --git a/ui/src/hooks/useHidRpc.ts b/ui/src/hooks/useHidRpc.ts index ae3055c5e..2d638a360 100644 --- a/ui/src/hooks/useHidRpc.ts +++ b/ui/src/hooks/useHidRpc.ts @@ -314,7 +314,7 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) { }; const errorHandler = (e: Event) => { - console.error(`Error on rpcHidChannel '${rpcHidChannel.label}': ${e}`); + console.error(`Error on rpcHidChannel '${rpcHidChannel.label}': ${e.type}`); }; rpcHidChannel.addEventListener("message", messageHandler); diff --git a/ui/src/hooks/useKeyboard.ts b/ui/src/hooks/useKeyboard.ts index f9c3dae96..619976eb4 100644 --- a/ui/src/hooks/useKeyboard.ts +++ b/ui/src/hooks/useKeyboard.ts @@ -39,6 +39,7 @@ export default function useKeyboard() { useHidStore(); const abortController = useRef(null); + const latchedModifierKeysRef = useRef>(new Set()); const setAbortController = useCallback((ac: AbortController | null) => { abortController.current = ac; }, []); @@ -157,6 +158,7 @@ export default function useKeyboard() { // Cancel keepalive since we're resetting the keyboard state cancelKeepAlive(); heldKeysRef.current.clear(); + latchedModifierKeysRef.current.clear(); // Reset the keys buffer to zeros and the modifier state to zero const { keys, modifier } = MACRO_RESET_KEYBOARD_STATE; if (rpcHidReady) { @@ -266,6 +268,12 @@ export default function useKeyboard() { if (rpcDataChannel?.readyState !== "open" && !rpcHidReady) return; if ((key || 0) === 0) return; // ignore zero key presses (they are bad mappings) + // If a modifier is currently latched by the virtual keyboard, + // a physical keyup for the same HID key must not release it. + if (!press && latchedModifierKeysRef.current.has(key)) { + return; + } + if (rpcHidReady) { // if the keyPress api is available, we can just send the key press event // sendKeypressEvent is used to send a single key press/release event to the device. @@ -298,6 +306,22 @@ export default function useKeyboard() { ], ); + const pressLatchedModifier = useCallback( + (key: number) => { + latchedModifierKeysRef.current.add(key); + void handleKeyPress(key, true); + }, + [handleKeyPress], + ); + + const releaseLatchedModifier = useCallback( + (key: number) => { + latchedModifierKeysRef.current.delete(key); + void handleKeyPress(key, false); + }, + [handleKeyPress], + ); + // Cleanup function to cancel keepalive timer const cleanup = useCallback(() => { cancelKeepAlive(); @@ -396,6 +420,27 @@ export default function useKeyboard() { [rpcHidReady, executeMacroRemote, executeMacroClientSide], ); + // executeHidMacro sends pre-built KeyboardMacroStep[] (scancode-based) directly, + // bypassing the name→scancode conversion in executeMacro. Used by the paste system + // when working with KLE charMap data that already provides scancodes and modifier bytes. + const executeHidMacro = useCallback( + async (steps: KeyboardMacroStep[]) => { + if (rpcHidReady) { + sendKeyboardMacroEventHidRpc(steps); + } else { + // Legacy path: send each step as a full keyboard report + const ac = new AbortController(); + setAbortController(ac); + for (const step of steps) { + if (ac.signal.aborted) break; + await sendKeystrokeLegacy(step.keys, step.modifier, ac); + await sleep(step.delay || 20); + } + } + }, + [rpcHidReady, sendKeyboardMacroEventHidRpc, sendKeystrokeLegacy, setAbortController], + ); + const cancelExecuteMacro = useCallback(async () => { if (abortController.current) { abortController.current.abort(); @@ -409,8 +454,11 @@ export default function useKeyboard() { return { handleKeyPress, + pressLatchedModifier, + releaseLatchedModifier, resetKeyboardState, executeMacro, + executeHidMacro, cleanup, cancelExecuteMacro, pauseKeepAlive, diff --git a/ui/src/hooks/useKeyboardLayout.ts b/ui/src/hooks/useKeyboardLayout.ts deleted file mode 100644 index f82912d69..000000000 --- a/ui/src/hooks/useKeyboardLayout.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { useMemo } from "react"; - -import { useSettingsStore } from "@/hooks/stores"; -import { keyboards } from "@/keyboardLayouts"; - -export default function useKeyboardLayout() { - const { keyboardLayout } = useSettingsStore(); - - const keyboardOptions = useMemo(() => { - return keyboards.map(keyboard => { - return { label: keyboard.name, value: keyboard.isoCode }; - }); - }, []); - - const isoCode = useMemo(() => { - // If we don't have a specific layout, default to "en-US" because that was the original layout - // developed so it is a good fallback. Additionally, we replace "en_US" with "en-US" because - // the original server-side code used "en_US" as the default value, but that's not the correct - // ISO code for English/United State. To ensure we remain backward compatible with devices that - // have not had their Keyboard Layout selected by the user, we want to treat "en_US" as if it was - // "en-US" to match the ISO standard codes now used in the keyboardLayouts. - console.debug("Current keyboard layout from store:", keyboardLayout); - if (keyboardLayout && keyboardLayout.length > 0) - return keyboardLayout.replace("en_US", "en-US"); - return "en-US"; - }, [keyboardLayout]); - - const selectedKeyboard = useMemo(() => { - // fallback to original behaviour of en-US if no isoCode given or matching layout not found - return ( - keyboards.find(keyboard => keyboard.isoCode === isoCode) ?? - keyboards.find(keyboard => keyboard.isoCode === "en-US")! - ); - }, [isoCode]); - - return { keyboardOptions, isoCode, selectedKeyboard }; -} diff --git a/ui/src/index.css b/ui/src/index.css index 693590613..0e6df8be6 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -214,140 +214,6 @@ video::-webkit-media-controls { border-radius: 5px; } -.simple-keyboard.hg-theme-default { - display: inline-block; -} - -.simple-keyboard-main.simple-keyboard { - @apply w-full md:w-[80%]; - background: none; -} - -.simple-keyboard-main.simple-keyboard .hg-row:first-child { - @apply mb-[10px]; -} - -.simple-keyboard-arrows.simple-keyboard { - @apply self-end; - background: none; -} - -.simple-keyboard .hg-button.selectedButton { - background: rgba(5, 25, 70, 0.53); - @apply text-white; -} - -.simple-keyboard .hg-button.emptySpace { - @apply pointer-events-none; - background: none; - border: none; - box-shadow: none; -} - -.simple-keyboard-arrows .hg-row { - justify-content: center; -} - -.simple-keyboard-arrows .hg-button { - @apply flex w-[50px] items-center justify-center; -} - -.controlArrows { - @apply flex w-full items-center justify-between md:w-1/5; - flex-flow: column; -} - -.simple-keyboard-control.simple-keyboard { - background: none; -} - -.simple-keyboard-control.simple-keyboard .hg-row:first-child { - margin-bottom: 10px; -} - -.controlArrows .simple-keyboard-control.simple-keyboard .hg-row:first-child { - @apply mb-[4px] md:mb-[10px]; -} - -.hg-button { - @apply dark:bg-slate-800! dark:text-white; -} - -.simple-keyboard-control .hg-button { - @apply flex w-[50px] items-center justify-center; -} - -.numPad { - @apply flex items-end; -} - -.simple-keyboard-numpad.simple-keyboard { - background: none; -} - -.simple-keyboard-numpad.simple-keyboard { - @apply w-[160px]; -} - -.simple-keyboard-numpad.simple-keyboard .hg-button { - @apply flex w-[50px] items-center justify-center; -} - -.simple-keyboard-numpadEnd.simple-keyboard { - @apply w-[50px]; - background: none; - margin: 0; - padding: 5px 5px 5px 0; -} - -.simple-keyboard-numpadEnd.simple-keyboard .hg-button { - @apply flex items-center justify-center; -} - -.simple-keyboard-numpadEnd .hg-button.hg-standardBtn.hg-button-plus { - @apply h-[85px]; -} - -.simple-keyboard-numpadEnd.simple-keyboard .hg-button.hg-button-enter { - @apply h-[85px]; -} - -.simple-keyboard.hg-theme-default .hg-button.hg-selectedButton { - background: rgba(5, 25, 70, 0.53); - @apply text-white; -} - -.hg-button.hg-standardBtn[data-skbtn="Space"] { - @apply md:w-[350px]!; -} - -.hg-theme-default .hg-row .combination-key { - @apply inline-flex h-auto! w-auto! grow-0 py-1 text-xs; -} - -.hg-theme-default .hg-row .down-key { - background: rgb(28, 28, 28); - @apply text-white! font-bold!; -} - -.hg-theme-default .hg-row .hg-button-container, -.hg-theme-default .hg-row .hg-button:not(:last-child) { - @apply mr-[2px]! md:mr-[5px]!; -} - -/* Reduce font size for selected keys when keyboard is detached */ -.keyboard-detached .simple-keyboard-main.simple-keyboard { - min-width: calc(14 * 7ch); -} - -.keyboard-detached .simple-keyboard.hg-theme-default div.hg-button { - text-wrap: auto; - text-align: center; - min-width: 6ch; -} -.keyboard-detached .simple-keyboard.hg-theme-default .hg-button span { - font-size: 50%; -} /* Hide the scrollbar by setting the scrollbar color to the background color */ .xterm .xterm-viewport { diff --git a/ui/src/keyDisplayNames.ts b/ui/src/keyDisplayNames.ts new file mode 100644 index 000000000..c8fcff7ac --- /dev/null +++ b/ui/src/keyDisplayNames.ts @@ -0,0 +1,62 @@ +/** + * Display name mappings for keyboard modifiers and keys. + * + * Modifier names are universal (not layout-specific). + * Key display names are synthesized from the active KLE layout at runtime + * via buildKeyDisplayMap(), so the macro UI shows the correct legends + * for the selected target keyboard. + */ + +import { keys } from "@/keyboardMappings"; +import type { KeyboardLayout } from "@components/keyboard/types/schema"; + +export const modifierDisplayMap: Record = { + ControlLeft: "Left Ctrl", + ControlRight: "Right Ctrl", + ShiftLeft: "Left Shift", + ShiftRight: "Right Shift", + AltLeft: "Left Alt", + AltRight: "Right Alt", + MetaLeft: "Left Meta", + MetaRight: "Right Meta", + AltGr: "AltGr", +}; + +/** + * Builds a key code → display name map from a KLE layout. + * + * Maps KeyboardEvent.code values (e.g. "KeyA", "ArrowUp") to the legend + * shown on that key in the target layout. For example, on German QWERTZ + * the "KeyZ" code maps to scancode 0x1C (Y position), whose legend is "y". + * + * Falls back to the code name itself for unmapped keys. + */ +export function buildKeyDisplayMap(layout: KeyboardLayout | null): Record { + const displayMap: Record = {}; + + if (layout) { + // Build scancode → legend lookup from the KLE layout + const scancodeToLegend = new Map(); + for (const key of layout.keys) { + if (key.scancode === 0) continue; + // Prefer the normal (unshifted) legend; skip multi-word labels like "Num Lock" + const legend = key.legends.normal ?? key.legends.shift; + if (legend && !scancodeToLegend.has(key.scancode)) { + scancodeToLegend.set(key.scancode, legend); + } + } + + // Map each KeyboardEvent.code → its HID scancode → the KLE legend + for (const [code, scancode] of Object.entries(keys)) { + const legend = scancodeToLegend.get(scancode); + displayMap[code] = legend ?? code; + } + } else { + // No layout loaded — just use the code names + for (const code of Object.keys(keys)) { + displayMap[code] = code; + } + } + + return displayMap; +} diff --git a/ui/src/keyboardLayouts.ts b/ui/src/keyboardLayouts.ts deleted file mode 100644 index 5dd9e15b7..000000000 --- a/ui/src/keyboardLayouts.ts +++ /dev/null @@ -1,68 +0,0 @@ -export interface KeyStroke { - modifier: number; - keys: number[]; -} -export interface KeyInfo { - key: string | number; - shift?: boolean; - altRight?: boolean; -} -export interface KeyCombo extends KeyInfo { - deadKey?: boolean; - accentKey?: KeyInfo; -} -export interface KeyboardLayout { - isoCode: string; - name: string; - chars: Record; - modifierDisplayMap: Record; - keyDisplayMap: Record; - virtualKeyboard: { - main: { default: string[]; shift: string[] }; - control?: { default: string[]; shift?: string[] }; - arrows?: { default: string[] }; - }; -} - -// To add a new layout, create a file like the above and add it to the list -import { cs_CZ } from "@/keyboardLayouts/cs_CZ"; -import { da_DK } from "@/keyboardLayouts/da_DK"; -import { de_CH } from "@/keyboardLayouts/de_CH"; -import { de_DE } from "@/keyboardLayouts/de_DE"; -import { en_US } from "@/keyboardLayouts/en_US"; -import { en_UK } from "@/keyboardLayouts/en_UK"; -import { es_ES } from "@/keyboardLayouts/es_ES"; -import { fr_BE } from "@/keyboardLayouts/fr_BE"; -import { fr_CH } from "@/keyboardLayouts/fr_CH"; -import { fr_FR } from "@/keyboardLayouts/fr_FR"; -import { hu_HU } from "@/keyboardLayouts/hu_HU"; -import { it_IT } from "@/keyboardLayouts/it_IT"; -import { ja_JP } from "@/keyboardLayouts/ja_JP"; -import { nb_NO } from "@/keyboardLayouts/nb_NO"; -import { pl_PL } from "@/keyboardLayouts/pl_PL"; -import { pt_PT } from "@/keyboardLayouts/pt_PT"; -import { sv_SE } from "@/keyboardLayouts/sv_SE"; -import { sl_SI } from "@/keyboardLayouts/sl_SI"; -import { ru_RU } from "@/keyboardLayouts/ru_RU"; - -export const keyboards: KeyboardLayout[] = [ - cs_CZ, - da_DK, - de_CH, - de_DE, - en_UK, - en_US, - es_ES, - fr_BE, - fr_CH, - fr_FR, - hu_HU, - it_IT, - ja_JP, - nb_NO, - pl_PL, - pt_PT, - sv_SE, - sl_SI, - ru_RU, -]; diff --git a/ui/src/keyboardLayouts/cs_CZ.ts b/ui/src/keyboardLayouts/cs_CZ.ts deleted file mode 100644 index 5815239bf..000000000 --- a/ui/src/keyboardLayouts/cs_CZ.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"; - -import { en_US } from "./en_US"; // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard - -const name = "Čeština"; -const isoCode = "cs-CZ"; - -const keyTrema: KeyCombo = { key: "Backslash" }; // tréma (umlaut), two dots placed above a vowel -const keyAcute: KeyCombo = { key: "Equal" }; // accent aigu (acute accent), mark ´ placed above the letter -const keyHat: KeyCombo = { key: "Digit3", shift: true, altRight: true }; // accent circonflexe (accent hat), mark ^ placed above the letter -const keyCaron: KeyCombo = { key: "Equal", shift: true }; // caron or haček (inverted hat), mark ˇ placed above the letter -const keyGrave: KeyCombo = { key: "Digit7", shift: true, altRight: true }; // accent grave, mark ` placed above the letter -const keyTilde: KeyCombo = { key: "Digit1", shift: true, altRight: true }; // tilde, mark ~ placed above the letter -const keyRing: KeyCombo = { key: "Backquote", shift: true }; // kroužek (little ring), mark ° placed above the letter -const keyOverdot: KeyCombo = { key: "Digit8", shift: true, altRight: true }; // overdot (dot above), mark ˙ placed above the letter -const keyHook: KeyCombo = { key: "Digit6", shift: true, altRight: true }; // ogonoek (little hook), mark ˛ placed beneath a letter -const keyCedille: KeyCombo = { key: "Equal", shift: true, altRight: true }; // accent cedille (cedilla), mark ¸ placed beneath a letter - -const chars = { - A: { key: "KeyA", shift: true }, - Ä: { key: "KeyA", shift: true, accentKey: keyTrema }, - Á: { key: "KeyA", shift: true, accentKey: keyAcute }, - Â: { key: "KeyA", shift: true, accentKey: keyHat }, - À: { key: "KeyA", shift: true, accentKey: keyGrave }, - Ã: { key: "KeyA", shift: true, accentKey: keyTilde }, - Ȧ: { key: "KeyA", shift: true, accentKey: keyOverdot }, - Ą: { key: "KeyA", shift: true, accentKey: keyHook }, - B: { key: "KeyB", shift: true }, - Ḃ: { key: "KeyB", shift: true, accentKEy: keyOverdot }, - C: { key: "KeyC", shift: true }, - Č: { key: "KeyC", shift: true, accentKey: keyCaron }, - Ċ: { key: "KeyC", shift: true, accentKey: keyOverdot }, - Ç: { key: "KeyC", shift: true, accentKey: keyCedille }, - D: { key: "KeyD", shift: true }, - Ď: { key: "KeyD", shift: true, accentKey: keyCaron }, - Ḋ: { key: "KeyD", shift: true, accentKey: keyOverdot }, - E: { key: "KeyE", shift: true }, - Ë: { key: "KeyE", shift: true, accentKey: keyTrema }, - É: { key: "KeyE", shift: true, accentKey: keyAcute }, - Ê: { key: "KeyE", shift: true, accentKey: keyHat }, - Ě: { key: "KeyE", shift: true, accentKey: keyCaron }, - È: { key: "KeyE", shift: true, accentKey: keyGrave }, - Ẽ: { key: "KeyE", shift: true, accentKey: keyTilde }, - Ė: { key: "KeyE", shift: true, accentKEy: keyOverdot }, - Ę: { key: "KeyE", shift: true, accentKey: keyHook }, - F: { key: "KeyF", shift: true }, - Ḟ: { key: "KeyF", shift: true, accentKey: keyOverdot }, - G: { key: "KeyG", shift: true }, - Ġ: { key: "KeyG", shift: true, accentKey: keyOverdot }, - H: { key: "KeyH", shift: true }, - Ḣ: { key: "KeyH", shift: true, accentKey: keyOverdot }, - I: { key: "KeyI", shift: true }, - Ï: { key: "KeyI", shift: true, accentKey: keyTrema }, - Í: { key: "KeyI", shift: true, accentKey: keyAcute }, - Î: { key: "KeyI", shift: true, accentKey: keyHat }, - Ì: { key: "KeyI", shift: true, accentKey: keyGrave }, - Ĩ: { key: "KeyI", shift: true, accentKey: keyTilde }, - İ: { key: "KeyI", shift: true, accentKey: keyOverdot }, - Į: { key: "KeyI", shift: true, accentKey: keyHook }, - J: { key: "KeyJ", shift: true }, - K: { key: "KeyK", shift: true }, - L: { key: "KeyL", shift: true }, - Ŀ: { key: "KeyL", shift: true }, - M: { key: "KeyM", shift: true }, - Ṁ: { key: "KeyM", shift: true }, - N: { key: "KeyN", shift: true }, - Ň: { key: "KeyN", shift: true, accentKey: keyCaron }, - Ñ: { key: "KeyN", shift: true, accentKey: keyTilde }, - Ṅ: { key: "KeyN", shift: true, accentKEy: keyOverdot }, - O: { key: "KeyO", shift: true }, - Ö: { key: "KeyO", shift: true, accentKey: keyTrema }, - Ó: { key: "KeyO", shift: true, accentKey: keyAcute }, - Ô: { key: "KeyO", shift: true, accentKey: keyHat }, - Ò: { key: "KeyO", shift: true, accentKey: keyGrave }, - Õ: { key: "KeyO", shift: true, accentKey: keyTilde }, - Ȯ: { key: "KeyO", shift: true, accentKey: keyOverdot }, - Ǫ: { key: "KeyO", shift: true, accentKey: keyHook }, - P: { key: "KeyP", shift: true }, - Ṗ: { key: "KeyP", shift: true, accentKey: keyOverdot }, - Q: { key: "KeyQ", shift: true }, - R: { key: "KeyR", shift: true }, - Ř: { key: "KeyR", shift: true, accentKey: keyCaron }, - Ṙ: { key: "KeyR", shift: true, accentKey: keyOverdot }, - S: { key: "KeyS", shift: true }, - Š: { key: "KeyS", shift: true, accentKey: keyCaron }, - Ṡ: { key: "KeyS", shift: true, accentKey: keyOverdot }, - T: { key: "KeyT", shift: true }, - Ť: { key: "KeyT", shift: true, accentKey: keyCaron }, - Ṫ: { key: "KeyT", shift: true, accentKey: keyOverdot }, - U: { key: "KeyU", shift: true }, - Ü: { key: "KeyU", shift: true, accentKey: keyTrema }, - Ú: { key: "KeyU", shift: true, accentKey: keyAcute }, - Û: { key: "KeyU", shift: true, accentKey: keyHat }, - Ù: { key: "KeyU", shift: true, accentKey: keyGrave }, - Ũ: { key: "KeyU", shift: true, accentKey: keyTilde }, - Ů: { key: "KeyU", shift: true, accentKey: keyRing }, - Ų: { key: "KeyU", shift: true, accentKey: keyHook }, - V: { key: "KeyV", shift: true }, - W: { key: "KeyW", shift: true }, - Ẇ: { key: "KeyW", shift: true, accentKey: keyOverdot }, - X: { key: "KeyX", shift: true }, - Ẋ: { key: "KeyX", shift: true, accentKey: keyOverdot }, - Y: { key: "KeyY", shift: true }, - Ý: { key: "KeyY", shift: true, accentKey: keyAcute }, - Ẏ: { key: "KeyY", shift: true, accentKey: keyOverdot }, - Z: { key: "KeyZ", shift: true }, - Ż: { key: "KeyZ", shift: true, accentKey: keyOverdot }, - a: { key: "KeyA" }, - ä: { key: "KeyA", accentKey: keyTrema }, - â: { key: "KeyA", accentKey: keyHat }, - à: { key: "KeyA", accentKey: keyGrave }, - ã: { key: "KeyA", accentKey: keyTilde }, - ȧ: { key: "KeyA", accentKey: keyOverdot }, - ą: { key: "KeyA", accentKey: keyHook }, - b: { key: "KeyB" }, - "{": { key: "KeyB", altRight: true }, - ḃ: { key: "KeyB", accentKey: keyOverdot }, - c: { key: "KeyC" }, - "&": { key: "KeyC", altRight: true }, - ç: { key: "KeyC", accentKey: keyCedille }, - ċ: { key: "KeyC", accentKey: keyOverdot }, - d: { key: "KeyD" }, - ď: { key: "KeyD", accentKey: keyCaron }, - ḋ: { key: "KeyD", accentKey: keyOverdot }, - Đ: { key: "KeyD", altRight: true }, - e: { key: "KeyE" }, - ë: { key: "KeyE", accentKey: keyTrema }, - ê: { key: "KeyE", accentKey: keyHat }, - ẽ: { key: "KeyE", accentKey: keyTilde }, - è: { key: "KeyE", accentKey: keyGrave }, - ė: { key: "KeyE", accentKey: keyOverdot }, - ę: { key: "KeyE", accentKey: keyHook }, - "€": { key: "KeyE", altRight: true }, - f: { key: "KeyF" }, - ḟ: { key: "KeyF", accentKey: keyOverdot }, - "[": { key: "KeyF", altRight: true }, - g: { key: "KeyG" }, - ġ: { key: "KeyG", accentKey: keyOverdot }, - "]": { key: "KeyF", altRight: true }, - h: { key: "KeyH" }, - ḣ: { key: "KeyH", accentKey: keyOverdot }, - i: { key: "KeyI" }, - ï: { key: "KeyI", accentKey: keyTrema }, - î: { key: "KeyI", accentKey: keyHat }, - ì: { key: "KeyI", accentKey: keyGrave }, - ĩ: { key: "KeyI", accentKey: keyTilde }, - ı: { key: "KeyI", accentKey: keyOverdot }, - į: { key: "KeyI", accentKey: keyHook }, - j: { key: "KeyJ" }, - ȷ: { key: "KeyJ", accentKey: keyOverdot }, - k: { key: "KeyK" }, - ł: { key: "KeyK", altRight: true }, - l: { key: "KeyL" }, - ŀ: { key: "KeyL", accentKey: keyOverdot }, - Ł: { key: "KeyL", altRight: true }, - m: { key: "KeyM" }, - ṁ: { key: "KeyM", accentKey: keyOverdot }, - n: { key: "KeyN" }, - "}": { key: "KeyN", altRight: true }, - ň: { key: "KeyN", accentKey: keyCaron }, - ñ: { key: "KeyN", accentKey: keyTilde }, - ṅ: { key: "KeyN", accentKey: keyOverdot }, - o: { key: "KeyO" }, - ö: { key: "Key0", accentKey: keyTrema }, - ó: { key: "KeyO", accentKey: keyAcute }, - ô: { key: "KeyO", accentKey: keyHat }, - ò: { key: "KeyO", accentKey: keyGrave }, - õ: { key: "KeyO", accentKey: keyTilde }, - ȯ: { key: "KeyO", accentKey: keyOverdot }, - ǫ: { key: "KeyO", accentKey: keyHook }, - p: { key: "KeyP" }, - ṗ: { key: "KeyP", accentKey: keyOverdot }, - q: { key: "KeyQ" }, - r: { key: "KeyR" }, - ṙ: { key: "KeyR", accentKey: keyOverdot }, - s: { key: "KeyS" }, - ṡ: { key: "KeyS", accentKey: keyOverdot }, - đ: { key: "KeyS", altRight: true }, - t: { key: "KeyT" }, - ť: { key: "KeyT", accentKey: keyCaron }, - ṫ: { key: "KeyT", accentKey: keyOverdot }, - u: { key: "KeyU" }, - ü: { key: "KeyU", accentKey: keyTrema }, - û: { key: "KeyU", accentKey: keyHat }, - ù: { key: "KeyU", accentKey: keyGrave }, - ũ: { key: "KeyU", accentKey: keyTilde }, - ų: { key: "KeyU", accentKey: keyHook }, - v: { key: "KeyV" }, - "@": { key: "KeyV", altRight: true }, - w: { key: "KeyW" }, - ẇ: { key: "KeyW", accentKey: keyOverdot }, - x: { key: "KeyX" }, - "#": { key: "KeyX", altRight: true }, - ẋ: { key: "KeyX", accentKey: keyOverdot }, - y: { key: "KeyY" }, - ẏ: { key: "KeyY", accentKey: keyOverdot }, - z: { key: "KeyZ" }, - ż: { key: "KeyZ", accentKey: keyOverdot }, - ";": { key: "Backquote" }, - "°": { key: "Backquote", shift: true, deadKey: true }, - "+": { key: "Digit1" }, - 1: { key: "Digit1", shift: true }, - ě: { key: "Digit2" }, - 2: { key: "Digit2", shift: true }, - š: { key: "Digit3" }, - 3: { key: "Digit3", shift: true }, - č: { key: "Digit4" }, - 4: { key: "Digit4", shift: true }, - ř: { key: "Digit5" }, - 5: { key: "Digit5", shift: true }, - ž: { key: "Digit6" }, - 6: { key: "Digit6", shift: true }, - ý: { key: "Digit7" }, - 7: { key: "Digit7", shift: true }, - á: { key: "Digit8" }, - 8: { key: "Digit8", shift: true }, - í: { key: "Digit9" }, - 9: { key: "Digit9", shift: true }, - é: { key: "Digit0" }, - 0: { key: "Digit0", shift: true }, - "=": { key: "Minus" }, - "%": { key: "Minus", shift: true }, - ú: { key: "BracketLeft" }, - "/": { key: "BracketLeft", shift: true }, - ")": { key: "BracketRight" }, - "(": { key: "BracketRight", shift: true }, - ů: { key: "Semicolon" }, - '"': { key: "Semicolon", shift: true }, - "§": { key: "Quote" }, - "!": { key: "Quote", shift: true }, - "'": { key: "Backslash", shift: true }, - ",": { key: "Comma" }, - "?": { key: "Comma", shift: true }, - "<": { key: "Comma", altRight: true }, - ".": { key: "Period" }, - ":": { key: "Period", shift: true }, - ">": { key: "Period", altRight: true }, - "-": { key: "Slash" }, - _: { key: "Slash", shift: true }, - "*": { key: "Slash", altRight: true }, - "\\": { key: "IntlBackslash" }, - "|": { key: "IntlBackslash", shift: true }, - " ": { key: "Space" }, - "\n": { key: "Enter" }, - Enter: { key: "Enter" }, - Tab: { key: "Tab" }, -} as Record; - -export const cs_CZ: KeyboardLayout = { - isoCode: isoCode, - name: name, - chars: chars, - // TODO need to localize these maps and layouts - keyDisplayMap: en_US.keyDisplayMap, - modifierDisplayMap: en_US.modifierDisplayMap, - virtualKeyboard: en_US.virtualKeyboard, -}; diff --git a/ui/src/keyboardLayouts/da_DK.ts b/ui/src/keyboardLayouts/da_DK.ts deleted file mode 100644 index 6fe074be7..000000000 --- a/ui/src/keyboardLayouts/da_DK.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"; - -import { en_US } from "./en_US"; // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard - -export const name = "Dansk"; -const isoCode = "da-DK"; - -const keyTrema = { key: "BracketRight" }; -const keyAcute = { key: "Equal", altRight: true }; -const keyHat = { key: "BracketRight", shift: true }; -const keyGrave = { key: "Equal", shift: true }; -const keyTilde = { key: "BracketRight", altRight: true }; - -export const chars = { - A: { key: "KeyA", shift: true }, - Ä: { key: "KeyA", shift: true, accentKey: keyTrema }, - Á: { key: "KeyA", shift: true, accentKey: keyAcute }, - Â: { key: "KeyA", shift: true, accentKey: keyHat }, - À: { key: "KeyA", shift: true, accentKey: keyGrave }, - Ã: { key: "KeyA", shift: true, accentKey: keyTilde }, - B: { key: "KeyB", shift: true }, - C: { key: "KeyC", shift: true }, - D: { key: "KeyD", shift: true }, - E: { key: "KeyE", shift: true }, - Ë: { key: "KeyE", shift: true, accentKey: keyTrema }, - É: { key: "KeyE", shift: true, accentKey: keyAcute }, - Ê: { key: "KeyE", shift: true, accentKey: keyHat }, - È: { key: "KeyE", shift: true, accentKey: keyGrave }, - Ẽ: { key: "KeyE", shift: true, accentKey: keyTilde }, - F: { key: "KeyF", shift: true }, - G: { key: "KeyG", shift: true }, - H: { key: "KeyH", shift: true }, - I: { key: "KeyI", shift: true }, - Ï: { key: "KeyI", shift: true, accentKey: keyTrema }, - Í: { key: "KeyI", shift: true, accentKey: keyAcute }, - Î: { key: "KeyI", shift: true, accentKey: keyHat }, - Ì: { key: "KeyI", shift: true, accentKey: keyGrave }, - Ĩ: { key: "KeyI", shift: true, accentKey: keyTilde }, - J: { key: "KeyJ", shift: true }, - K: { key: "KeyK", shift: true }, - L: { key: "KeyL", shift: true }, - M: { key: "KeyM", shift: true }, - N: { key: "KeyN", shift: true }, - O: { key: "KeyO", shift: true }, - Ö: { key: "KeyO", shift: true, accentKey: keyTrema }, - Ó: { key: "KeyO", shift: true, accentKey: keyAcute }, - Ô: { key: "KeyO", shift: true, accentKey: keyHat }, - Ò: { key: "KeyO", shift: true, accentKey: keyGrave }, - Õ: { key: "KeyO", shift: true, accentKey: keyTilde }, - P: { key: "KeyP", shift: true }, - Q: { key: "KeyQ", shift: true }, - R: { key: "KeyR", shift: true }, - S: { key: "KeyS", shift: true }, - T: { key: "KeyT", shift: true }, - U: { key: "KeyU", shift: true }, - Ü: { key: "KeyU", shift: true, accentKey: keyTrema }, - Ú: { key: "KeyU", shift: true, accentKey: keyAcute }, - Û: { key: "KeyU", shift: true, accentKey: keyHat }, - Ù: { key: "KeyU", shift: true, accentKey: keyGrave }, - Ũ: { key: "KeyU", shift: true, accentKey: keyTilde }, - V: { key: "KeyV", shift: true }, - W: { key: "KeyW", shift: true }, - X: { key: "KeyX", shift: true }, - Y: { key: "KeyY", shift: true }, - Z: { key: "KeyZ", shift: true }, - a: { key: "KeyA" }, - ä: { key: "KeyA", accentKey: keyTrema }, - á: { key: "KeyA", accentKey: keyAcute }, - â: { key: "KeyA", accentKey: keyHat }, - à: { key: "KeyA", accentKey: keyGrave }, - ã: { key: "KeyA", accentKey: keyTilde }, - b: { key: "KeyB" }, - c: { key: "KeyC" }, - d: { key: "KeyD" }, - e: { key: "KeyE" }, - ë: { key: "KeyE", accentKey: keyTrema }, - é: { key: "KeyE", accentKey: keyAcute }, - ê: { key: "KeyE", accentKey: keyHat }, - è: { key: "KeyE", accentKey: keyGrave }, - ẽ: { key: "KeyE", accentKey: keyTilde }, - "€": { key: "KeyE", altRight: true }, - f: { key: "KeyF" }, - g: { key: "KeyG" }, - h: { key: "KeyH" }, - i: { key: "KeyI" }, - ï: { key: "KeyI", accentKey: keyTrema }, - í: { key: "KeyI", accentKey: keyAcute }, - î: { key: "KeyI", accentKey: keyHat }, - ì: { key: "KeyI", accentKey: keyGrave }, - ĩ: { key: "KeyI", accentKey: keyTilde }, - j: { key: "KeyJ" }, - k: { key: "KeyK" }, - l: { key: "KeyL" }, - m: { key: "KeyM" }, - n: { key: "KeyN" }, - o: { key: "KeyO" }, - ö: { key: "KeyO", accentKey: keyTrema }, - ó: { key: "KeyO", accentKey: keyAcute }, - ô: { key: "KeyO", accentKey: keyHat }, - ò: { key: "KeyO", accentKey: keyGrave }, - õ: { key: "KeyO", accentKey: keyTilde }, - p: { key: "KeyP" }, - q: { key: "KeyQ" }, - r: { key: "KeyR" }, - s: { key: "KeyS" }, - t: { key: "KeyT" }, - u: { key: "KeyU" }, - ü: { key: "KeyU", accentKey: keyTrema }, - ú: { key: "KeyU", accentKey: keyAcute }, - û: { key: "KeyU", accentKey: keyHat }, - ù: { key: "KeyU", accentKey: keyGrave }, - ũ: { key: "KeyU", accentKey: keyTilde }, - v: { key: "KeyV" }, - w: { key: "KeyW" }, - x: { key: "KeyX" }, - y: { key: "KeyY" }, // <-- corrected - z: { key: "KeyZ" }, // <-- corrected - "½": { key: "Backquote" }, - "§": { key: "Backquote", shift: true }, - 1: { key: "Digit1" }, - "!": { key: "Digit1", shift: true }, - 2: { key: "Digit2" }, - '"': { key: "Digit2", shift: true }, - "@": { key: "Digit2", altRight: true }, - 3: { key: "Digit3" }, - "#": { key: "Digit3", shift: true }, - "£": { key: "Digit3", altRight: true }, - 4: { key: "Digit4" }, - "¤": { key: "Digit4", shift: true }, - $: { key: "Digit4", altRight: true }, - 5: { key: "Digit5" }, - "%": { key: "Digit5", shift: true }, - 6: { key: "Digit6" }, - "&": { key: "Digit6", shift: true }, - 7: { key: "Digit7" }, - "/": { key: "Digit7", shift: true }, - "{": { key: "Digit7", altRight: true }, - 8: { key: "Digit8" }, - "(": { key: "Digit8", shift: true }, - "[": { key: "Digit8", altRight: true }, - 9: { key: "Digit9" }, - ")": { key: "Digit9", shift: true }, - "]": { key: "Digit9", altRight: true }, - 0: { key: "Digit0" }, - "=": { key: "Digit0", shift: true }, - "}": { key: "Digit0", altRight: true }, - "+": { key: "Minus" }, - "?": { key: "Minus", shift: true }, - "\\": { key: "Equal" }, - å: { key: "BracketLeft" }, - Å: { key: "BracketLeft", shift: true }, - ø: { key: "Semicolon" }, - Ø: { key: "Semicolon", shift: true }, - æ: { key: "Quote" }, - Æ: { key: "Quote", shift: true }, - "'": { key: "Backslash" }, - "*": { key: "Backslash", shift: true }, - ",": { key: "Comma" }, - ";": { key: "Comma", shift: true }, - ".": { key: "Period" }, - ":": { key: "Period", shift: true }, - "-": { key: "Slash" }, - _: { key: "Slash", shift: true }, - "<": { key: "IntlBackslash" }, - ">": { key: "IntlBackslash", shift: true }, - "~": { key: "BracketRight", deadKey: true, altRight: true }, - "^": { key: "BracketRight", deadKey: true, shift: true }, - "¨": { key: "BracketRight", deadKey: true }, - "|": { key: "Equal", deadKey: true, altRight: true }, - "`": { key: "Equal", deadKey: true, shift: true }, - "´": { key: "Equal", deadKey: true }, - " ": { key: "Space" }, - "\n": { key: "Enter" }, - Enter: { key: "Enter" }, - Tab: { key: "Tab" }, -} as Record; - -export const da_DK: KeyboardLayout = { - isoCode: isoCode, - name: name, - chars: chars, - // TODO need to localize these maps and layouts - keyDisplayMap: en_US.keyDisplayMap, - modifierDisplayMap: en_US.modifierDisplayMap, - virtualKeyboard: en_US.virtualKeyboard, -}; diff --git a/ui/src/keyboardLayouts/de_CH.ts b/ui/src/keyboardLayouts/de_CH.ts deleted file mode 100644 index bb348600d..000000000 --- a/ui/src/keyboardLayouts/de_CH.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"; - -import { en_US } from "./en_US"; // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard - -const name = "Schwiizerdütsch"; -const isoCode = "de-CH"; - -const keyTrema: KeyCombo = { key: "BracketRight" }; // tréma (umlaut), two dots placed above a vowel -const keyAcute: KeyCombo = { key: "Minus", altRight: true }; // accent aigu (acute accent), mark ´ placed above the letter -const keyHat: KeyCombo = { key: "Equal" }; // accent circonflexe (accent hat), mark ^ placed above the letter -const keyGrave: KeyCombo = { key: "Equal", shift: true }; // accent grave, mark ` placed above the letter -const keyTilde: KeyCombo = { key: "Equal", altRight: true }; // tilde, mark ~ placed above the letter - -const chars = { - A: { key: "KeyA", shift: true }, - Ä: { key: "KeyA", shift: true, accentKey: keyTrema }, - Á: { key: "KeyA", shift: true, accentKey: keyAcute }, - Â: { key: "KeyA", shift: true, accentKey: keyHat }, - À: { key: "KeyA", shift: true, accentKey: keyGrave }, - Ã: { key: "KeyA", shift: true, accentKey: keyTilde }, - B: { key: "KeyB", shift: true }, - C: { key: "KeyC", shift: true }, - D: { key: "KeyD", shift: true }, - E: { key: "KeyE", shift: true }, - Ë: { key: "KeyE", shift: true, accentKey: keyTrema }, - É: { key: "KeyE", shift: true, accentKey: keyAcute }, - Ê: { key: "KeyE", shift: true, accentKey: keyHat }, - È: { key: "KeyE", shift: true, accentKey: keyGrave }, - Ẽ: { key: "KeyE", shift: true, accentKey: keyTilde }, - F: { key: "KeyF", shift: true }, - G: { key: "KeyG", shift: true }, - H: { key: "KeyH", shift: true }, - I: { key: "KeyI", shift: true }, - Ï: { key: "KeyI", shift: true, accentKey: keyTrema }, - Í: { key: "KeyI", shift: true, accentKey: keyAcute }, - Î: { key: "KeyI", shift: true, accentKey: keyHat }, - Ì: { key: "KeyI", shift: true, accentKey: keyGrave }, - Ĩ: { key: "KeyI", shift: true, accentKey: keyTilde }, - J: { key: "KeyJ", shift: true }, - K: { key: "KeyK", shift: true }, - L: { key: "KeyL", shift: true }, - M: { key: "KeyM", shift: true }, - N: { key: "KeyN", shift: true }, - O: { key: "KeyO", shift: true }, - Ö: { key: "KeyO", shift: true, accentKey: keyTrema }, - Ó: { key: "KeyO", shift: true, accentKey: keyAcute }, - Ô: { key: "KeyO", shift: true, accentKey: keyHat }, - Ò: { key: "KeyO", shift: true, accentKey: keyGrave }, - Õ: { key: "KeyO", shift: true, accentKey: keyTilde }, - P: { key: "KeyP", shift: true }, - Q: { key: "KeyQ", shift: true }, - R: { key: "KeyR", shift: true }, - S: { key: "KeyS", shift: true }, - T: { key: "KeyT", shift: true }, - U: { key: "KeyU", shift: true }, - Ü: { key: "KeyU", shift: true, accentKey: keyTrema }, - Ú: { key: "KeyU", shift: true, accentKey: keyAcute }, - Û: { key: "KeyU", shift: true, accentKey: keyHat }, - Ù: { key: "KeyU", shift: true, accentKey: keyGrave }, - Ũ: { key: "KeyU", shift: true, accentKey: keyTilde }, - V: { key: "KeyV", shift: true }, - W: { key: "KeyW", shift: true }, - X: { key: "KeyX", shift: true }, - Y: { key: "KeyZ", shift: true }, - Z: { key: "KeyY", shift: true }, - a: { key: "KeyA" }, - á: { key: "KeyA", accentKey: keyAcute }, - â: { key: "KeyA", accentKey: keyHat }, - ã: { key: "KeyA", accentKey: keyTilde }, - b: { key: "KeyB" }, - c: { key: "KeyC" }, - d: { key: "KeyD" }, - e: { key: "KeyE" }, - ë: { key: "KeyE", accentKey: keyTrema }, - ê: { key: "KeyE", accentKey: keyHat }, - ẽ: { key: "KeyE", accentKey: keyTilde }, - "€": { key: "KeyE", altRight: true }, - f: { key: "KeyF" }, - g: { key: "KeyG" }, - h: { key: "KeyH" }, - i: { key: "KeyI" }, - ï: { key: "KeyI", accentKey: keyTrema }, - í: { key: "KeyI", accentKey: keyAcute }, - î: { key: "KeyI", accentKey: keyHat }, - ì: { key: "KeyI", accentKey: keyGrave }, - ĩ: { key: "KeyI", accentKey: keyTilde }, - j: { key: "KeyJ" }, - k: { key: "KeyK" }, - l: { key: "KeyL" }, - m: { key: "KeyM" }, - n: { key: "KeyN" }, - o: { key: "KeyO" }, - ó: { key: "KeyO", accentKey: keyAcute }, - ô: { key: "KeyO", accentKey: keyHat }, - ò: { key: "KeyO", accentKey: keyGrave }, - õ: { key: "KeyO", accentKey: keyTilde }, - p: { key: "KeyP" }, - q: { key: "KeyQ" }, - r: { key: "KeyR" }, - s: { key: "KeyS" }, - t: { key: "KeyT" }, - u: { key: "KeyU" }, - ú: { key: "KeyU", accentKey: keyAcute }, - û: { key: "KeyU", accentKey: keyHat }, - ù: { key: "KeyU", accentKey: keyGrave }, - ũ: { key: "KeyU", accentKey: keyTilde }, - v: { key: "KeyV" }, - w: { key: "KeyW" }, - x: { key: "KeyX" }, - y: { key: "KeyZ" }, - z: { key: "KeyY" }, - "§": { key: "Backquote" }, - "°": { key: "Backquote", shift: true }, - 1: { key: "Digit1" }, - "+": { key: "Digit1", shift: true }, - "|": { key: "Digit1", altRight: true }, - 2: { key: "Digit2" }, - '"': { key: "Digit2", shift: true }, - "@": { key: "Digit2", altRight: true }, - 3: { key: "Digit3" }, - "*": { key: "Digit3", shift: true }, - "#": { key: "Digit3", altRight: true }, - 4: { key: "Digit4" }, - ç: { key: "Digit4", shift: true }, - 5: { key: "Digit5" }, - "%": { key: "Digit5", shift: true }, - 6: { key: "Digit6" }, - "&": { key: "Digit6", shift: true }, - 7: { key: "Digit7" }, - "/": { key: "Digit7", shift: true }, - 8: { key: "Digit8" }, - "(": { key: "Digit8", shift: true }, - 9: { key: "Digit9" }, - ")": { key: "Digit9", shift: true }, - 0: { key: "Digit0" }, - "=": { key: "Digit0", shift: true }, - "'": { key: "Minus" }, - "?": { key: "Minus", shift: true }, - "^": { key: "Equal", deadKey: true }, - "`": { key: "Equal", shift: true }, - "~": { key: "Equal", altRight: true, deadKey: true }, - ü: { key: "BracketLeft" }, - è: { key: "BracketLeft", shift: true }, - "[": { key: "BracketLeft", altRight: true }, - "!": { key: "BracketRight", shift: true }, - "]": { key: "BracketRight", altRight: true }, - ö: { key: "Semicolon" }, - é: { key: "Semicolon", shift: true }, - ä: { key: "Quote" }, - à: { key: "Quote", shift: true }, - "{": { key: "Quote", altRight: true }, - $: { key: "Backslash" }, - "£": { key: "Backslash", shift: true }, - "}": { key: "Backslash", altRight: true }, - ",": { key: "Comma" }, - ";": { key: "Comma", shift: true }, - ".": { key: "Period" }, - ":": { key: "Period", shift: true }, - "-": { key: "Slash" }, - _: { key: "Slash", shift: true }, - "<": { key: "IntlBackslash" }, - ">": { key: "IntlBackslash", shift: true }, - "\\": { key: "IntlBackslash", altRight: true }, - " ": { key: "Space" }, - "\n": { key: "Enter" }, - Enter: { key: "Enter" }, - Tab: { key: "Tab" }, -} as Record; - -const keyDisplayMap = { - ...en_US.keyDisplayMap, - BracketLeft: "è", - "(BracketLeft)": "ü", - Semicolon: "é", - "(Semicolon)": "ö", - Quote: "à", - "(Quote)": "ä", -} as Record; - -export const de_CH: KeyboardLayout = { - isoCode: isoCode, - name: name, - chars: chars, - keyDisplayMap: keyDisplayMap, - // TODO need to localize these maps and layouts - modifierDisplayMap: en_US.modifierDisplayMap, - virtualKeyboard: en_US.virtualKeyboard, -}; diff --git a/ui/src/keyboardLayouts/de_DE.ts b/ui/src/keyboardLayouts/de_DE.ts deleted file mode 100644 index 2b079cb80..000000000 --- a/ui/src/keyboardLayouts/de_DE.ts +++ /dev/null @@ -1,337 +0,0 @@ -import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"; - -import { en_US } from "./en_US"; // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard - -const name = "Deutsch"; -const isoCode = "de-DE"; - -const keyAcute: KeyCombo = { key: "Equal" }; // accent aigu (acute accent), mark ´ placed above the letter -const keyHat: KeyCombo = { key: "Backquote" }; // accent circonflexe (accent hat), mark ^ placed above the letter -const keyGrave: KeyCombo = { key: "Equal", shift: true }; // accent grave, mark ` placed above the letter - -const chars = { - a: { key: "KeyA" }, - á: { key: "KeyA", accentKey: keyAcute }, - â: { key: "KeyA", accentKey: keyHat }, - à: { key: "KeyA", accentKey: keyGrave }, - A: { key: "KeyA", shift: true }, - Á: { key: "KeyA", shift: true, accentKey: keyAcute }, - Â: { key: "KeyA", shift: true, accentKey: keyHat }, - À: { key: "KeyA", shift: true, accentKey: keyGrave }, - "☺": { key: "KeyA", altRight: true }, // white smiling face ☺ - b: { key: "KeyB" }, - B: { key: "KeyB", shift: true }, - "‹": { key: "KeyB", altRight: true }, // single left-pointing angle quotation mark, ‹ - c: { key: "KeyC" }, - C: { key: "KeyC", shift: true }, - "\u202f": { key: "KeyC", altRight: true }, // narrow no-break space - d: { key: "KeyD" }, - D: { key: "KeyD", shift: true }, - "′": { key: "KeyD", altRight: true }, // prime, mark ′ placed above the letter - e: { key: "KeyE" }, - é: { key: "KeyE", accentKey: keyAcute }, - ê: { key: "KeyE", accentKey: keyHat }, - è: { key: "KeyE", accentKey: keyGrave }, - "€": { key: "KeyE", altRight: true }, - E: { key: "KeyE", shift: true }, - É: { key: "KeyE", shift: true, accentKey: keyAcute }, - Ê: { key: "KeyE", shift: true, accentKey: keyHat }, - È: { key: "KeyE", shift: true, accentKey: keyGrave }, - f: { key: "KeyF" }, - F: { key: "KeyF", shift: true }, - "˟": { key: "KeyF", deadKey: true, altRight: true }, // modifier letter cross accent, ˟ - G: { key: "KeyG", shift: true }, - g: { key: "KeyG" }, - ẞ: { key: "KeyG", altRight: true }, // capital sharp S, ẞ - h: { key: "KeyH" }, - H: { key: "KeyH", shift: true }, - ˍ: { key: "KeyH", deadKey: true, altRight: true }, // modifier letter low macron, ˍ - i: { key: "KeyI" }, - í: { key: "KeyI", accentKey: keyAcute }, - î: { key: "KeyI", accentKey: keyHat }, - ì: { key: "KeyI", accentKey: keyGrave }, - I: { key: "KeyI", shift: true }, - Í: { key: "KeyI", shift: true, accentKey: keyAcute }, - Î: { key: "KeyI", shift: true, accentKey: keyHat }, - Ì: { key: "KeyI", shift: true, accentKey: keyGrave }, - "˜": { key: "KeyI", deadKey: true, altRight: true }, // tilde accent, mark ˜ placed above the letter - j: { key: "KeyJ" }, - J: { key: "KeyJ", shift: true }, - "¸": { key: "KeyJ", deadKey: true, altRight: true }, // cedilla accent, mark ¸ placed below the letter - k: { key: "KeyK" }, - K: { key: "KeyK", shift: true }, - l: { key: "KeyL" }, - L: { key: "KeyL", shift: true }, - ˏ: { key: "KeyL", deadKey: true, altRight: true }, // modifier letter reversed comma, ˏ - m: { key: "KeyM" }, - M: { key: "KeyM", shift: true }, - µ: { key: "KeyM", altRight: true }, - n: { key: "KeyN" }, - N: { key: "KeyN", shift: true }, - "–": { key: "KeyN", altRight: true }, // en dash, – - o: { key: "KeyO" }, - ó: { key: "KeyO", accentKey: keyAcute }, - ô: { key: "KeyO", accentKey: keyHat }, - ò: { key: "KeyO", accentKey: keyGrave }, - O: { key: "KeyO", shift: true }, - Ó: { key: "KeyO", shift: true, accentKey: keyAcute }, - Ô: { key: "KeyO", shift: true, accentKey: keyHat }, - Ò: { key: "KeyO", shift: true, accentKey: keyGrave }, - "˚": { key: "KeyO", deadKey: true, altRight: true }, // ring above, ˚ - p: { key: "KeyP" }, - P: { key: "KeyP", shift: true }, - ˀ: { key: "KeyP", deadKey: true, altRight: true }, // modifier letter apostrophe, ʾ - q: { key: "KeyQ" }, - Q: { key: "KeyQ", shift: true }, - "@": { key: "KeyQ", altRight: true }, - R: { key: "KeyR", shift: true }, - r: { key: "KeyR" }, - "˝": { key: "KeyR", deadKey: true, altRight: true }, // double acute accent, mark ˝ placed above the letter - S: { key: "KeyS", shift: true }, - s: { key: "KeyS" }, - "″": { key: "KeyS", altRight: true }, // double prime, mark ″ placed above the letter - T: { key: "KeyT", shift: true }, - t: { key: "KeyT" }, - ˇ: { key: "KeyT", deadKey: true, altRight: true }, // caron/hacek accent, mark ˇ placed above the letter - u: { key: "KeyU" }, - ú: { key: "KeyU", accentKey: keyAcute }, - û: { key: "KeyU", accentKey: keyHat }, - ù: { key: "KeyU", accentKey: keyGrave }, - U: { key: "KeyU", shift: true }, - Ú: { key: "KeyU", shift: true, accentKey: keyAcute }, - Û: { key: "KeyU", shift: true, accentKey: keyHat }, - Ù: { key: "KeyU", shift: true, accentKey: keyGrave }, - "˘": { key: "KeyU", deadKey: true, altRight: true }, // breve accent, ˘ placed above the letter - v: { key: "KeyV" }, - V: { key: "KeyV", shift: true }, - "«": { key: "KeyV", altRight: true }, // left-pointing double angle quotation mark, « - w: { key: "KeyW" }, - W: { key: "KeyW", shift: true }, - "¯": { key: "KeyW", deadKey: true, altRight: true }, // macron accent, mark ¯ placed above the letter - x: { key: "KeyX" }, - X: { key: "KeyX", shift: true }, - "»": { key: "KeyX", altRight: true }, - // cross key between shift and y (aka OEM 102 key) - y: { key: "KeyZ" }, - Y: { key: "KeyZ", shift: true }, - "›": { key: "KeyZ", altRight: true }, // single right-pointing angle quotation mark, › - z: { key: "KeyY" }, - Z: { key: "KeyY", shift: true }, - "¨": { key: "KeyY", deadKey: true, altRight: true }, // diaeresis accent, mark ¨ placed above the letter - "°": { key: "Backquote", shift: true }, - "^": { key: "Backquote", deadKey: true }, - "|": { key: "Backquote", altRight: true }, - 1: { key: "Digit1" }, - "!": { key: "Digit1", shift: true }, - "’": { key: "Digit1", altRight: true }, // single quote, mark ’ placed above the letter - 2: { key: "Digit2" }, - '"': { key: "Digit2", shift: true }, - "²": { key: "Digit2", altRight: true }, - "<": { key: "Digit2", altRight: true }, // non-US < and > - 3: { key: "Digit3" }, - "§": { key: "Digit3", shift: true }, - "³": { key: "Digit3", altRight: true }, - ">": { key: "Digit3", altRight: true }, // non-US < and > - 4: { key: "Digit4" }, - $: { key: "Digit4", shift: true }, - "—": { key: "Digit4", altRight: true }, // em dash, — - 5: { key: "Digit5" }, - "%": { key: "Digit5", shift: true }, - "¡": { key: "Digit5", altRight: true }, // inverted exclamation mark, ¡ - 6: { key: "Digit6" }, - "&": { key: "Digit6", shift: true }, - "¿": { key: "Digit6", altRight: true }, // inverted question mark, ¿ - 7: { key: "Digit7" }, - "/": { key: "Digit7", shift: true }, - "{": { key: "Digit7", altRight: true }, - 8: { key: "Digit8" }, - "(": { key: "Digit8", shift: true }, - "[": { key: "Digit8", altRight: true }, - 9: { key: "Digit9" }, - ")": { key: "Digit9", shift: true }, - "]": { key: "Digit9", altRight: true }, - 0: { key: "Digit0" }, - "=": { key: "Digit0", shift: true }, - "}": { key: "Digit0", altRight: true }, - ß: { key: "Minus" }, - "?": { key: "Minus", shift: true }, - "\\": { key: "Minus", altRight: true }, - "´": { key: "Equal", deadKey: true }, // accent acute, mark ´ placed above the letter - "`": { key: "Equal", shift: true, deadKey: true }, // accent grave, mark ` placed above the letter - "˙": { key: "Equal", control: true, altRight: true, deadKey: true }, // acute accent, mark ˙ placed above the letter - ü: { key: "BracketLeft" }, - Ü: { key: "BracketLeft", shift: true }, - Escape: { key: "BracketLeft", control: true }, - ʼ: { key: "BracketLeft", altRight: true }, // modifier letter apostrophe, ʼ - "+": { key: "BracketRight" }, - "*": { key: "BracketRight", shift: true }, - Control: { key: "BracketRight", control: true }, - "~": { key: "BracketRight", altRight: true }, - ö: { key: "Semicolon" }, - Ö: { key: "Semicolon", shift: true }, - ˌ: { key: "Semicolon", deadkey: true, altRight: true }, // modifier letter low vertical line, ˌ - ä: { key: "Quote" }, - Ä: { key: "Quote", shift: true }, - "˗": { key: "Quote", deadKey: true, altRight: true }, // modifier letter minus sign, ˗ - "#": { key: "Backslash" }, - "'": { key: "Backslash", shift: true }, - "−": { key: "Backslash", altRight: true }, // minus sign, − - ",": { key: "Comma" }, - ";": { key: "Comma", shift: true }, - "\u2011": { key: "Comma", altRight: true }, // non-breaking hyphen, ‑ - ".": { key: "Period" }, - ":": { key: "Period", shift: true }, - "·": { key: "Period", altRight: true }, // middle dot, · - "-": { key: "Slash" }, - _: { key: "Slash", shift: true }, - "\u00ad": { key: "Slash", altRight: true }, // soft hyphen, ­ - " ": { key: "Space" }, - "\n": { key: "Enter" }, - Enter: { key: "Enter" }, - Tab: { key: "Tab" }, -} as Record; - -export const keyDisplayMap: Record = { - ...en_US.keyDisplayMap, - // now override the English keyDisplayMap with German specific keys - - // Combination keys - CtrlAltDelete: "Strg + Alt + Entf", - CtrlAltBackspace: "Strg + Alt + ←", - - // German action keys - AltLeft: "Alt", - AltRight: "AltGr", - Backspace: "Rücktaste", - "(Backspace)": "Rücktaste", - CapsLock: "Feststelltaste", - Clear: "Entf", - ControlLeft: "Strg", - ControlRight: "Strg", - Delete: "Entf", - End: "Ende", - Enter: "Eingabe", - Escape: "Esc", - Home: "Pos1", - Insert: "Einfg", - Menu: "Menü", - MetaLeft: "Meta", - MetaRight: "Meta", - PageDown: "Bild ↓", - PageUp: "Bild ↑", - ShiftLeft: "Umschalt", - ShiftRight: "Umschalt", - - // German umlauts and ß - BracketLeft: "ü", - "(BracketLeft)": "Ü", - Semicolon: "ö", - "(Semicolon)": "Ö", - Quote: "ä", - "(Quote)": "Ä", - Minus: "ß", - "(Minus)": "?", - Equal: "´", - "(Equal)": "`", - Backslash: "#", - "(Backslash)": "'", - - // Shifted Numbers - "(Digit2)": '"', - "(Digit3)": "§", - "(Digit6)": "&", - "(Digit7)": "/", - "(Digit8)": "(", - "(Digit9)": ")", - "(Digit0)": "=", - - // Additional German symbols - Backquote: "^", - "(Backquote)": "°", - Comma: ",", - "(Comma)": ";", - Period: ".", - "(Period)": ":", - Slash: "-", - "(Slash)": "_", - - // Numpad - NumpadDecimal: "Num ,", - NumpadEnter: "Num Eingabe", - NumpadInsert: "Einfg", - NumpadDelete: "Entf", - - // Modals - PrintScreen: "Druck", - ScrollLock: "Rollen", - "(Pause)": "Unterbr", -}; - -export const modifierDisplayMap: Record = { - ShiftLeft: "Umschalt (links)", - ShiftRight: "Umschalt (rechts)", - ControlLeft: "Strg (links)", - ControlRight: "Strg (rechts)", - AltLeft: "Alt", - AltRight: "AltGr", - MetaLeft: "Meta (links)", - MetaRight: "Meta (rechts)", - AltGr: "AltGr", -} as Record; - -export const virtualKeyboard = { - main: { - default: [ - "CtrlAltDelete AltMetaEscape CtrlAltBackspace", - "Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12", - "Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace", - "Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight", - "CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Backslash Enter", - "ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight", - "ControlLeft MetaLeft AltLeft Space AltGr MetaRight Menu ControlRight", - ], - shift: [ - "CtrlAltDelete AltMetaEscape CtrlAltBackspace", - "Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12", - "(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Backspace)", - "Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)", - "CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter", - "ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight", - "ControlLeft MetaLeft AltLeft Space AltGr MetaRight Menu ControlRight", - ], - }, - control: { - default: ["PrintScreen ScrollLock Pause", "Insert Home PageUp", "Delete End PageDown"], - shift: ["(PrintScreen) ScrollLock (Pause)", "Insert Home PageUp", "Delete End PageDown"], - }, - - arrows: { - default: [" ArrowUp ", "ArrowLeft ArrowDown ArrowRight"], - }, - - numpad: { - numlocked: [ - "NumLock NumpadDivide NumpadMultiply NumpadSubtract", - "Numpad7 Numpad8 Numpad9 NumpadAdd", - "Numpad4 Numpad5 Numpad6", - "Numpad1 Numpad2 Numpad3 NumpadEnter", - "Numpad0 NumpadDecimal", - ], - default: [ - "NumLock NumpadDivide NumpadMultiply NumpadSubtract", - "Home ArrowUp PageUp NumpadAdd", - "ArrowLeft Clear ArrowRight", - "End ArrowDown PageDown NumpadEnter", - "NumpadInsert NumpadDelete", - ], - }, -}; - -export const de_DE: KeyboardLayout = { - isoCode: isoCode, - name: name, - chars: chars, - keyDisplayMap: keyDisplayMap, - modifierDisplayMap: modifierDisplayMap, - virtualKeyboard: virtualKeyboard, -}; diff --git a/ui/src/keyboardLayouts/en_UK.ts b/ui/src/keyboardLayouts/en_UK.ts deleted file mode 100644 index 2b08e8f3e..000000000 --- a/ui/src/keyboardLayouts/en_UK.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"; - -import { en_US } from "./en_US"; // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard - -const name = "English (UK)"; -const isoCode = "en-UK"; - -const chars = { - A: { key: "KeyA", shift: true }, - B: { key: "KeyB", shift: true }, - C: { key: "KeyC", shift: true }, - D: { key: "KeyD", shift: true }, - E: { key: "KeyE", shift: true }, - F: { key: "KeyF", shift: true }, - G: { key: "KeyG", shift: true }, - H: { key: "KeyH", shift: true }, - I: { key: "KeyI", shift: true }, - J: { key: "KeyJ", shift: true }, - K: { key: "KeyK", shift: true }, - L: { key: "KeyL", shift: true }, - M: { key: "KeyM", shift: true }, - N: { key: "KeyN", shift: true }, - O: { key: "KeyO", shift: true }, - P: { key: "KeyP", shift: true }, - Q: { key: "KeyQ", shift: true }, - R: { key: "KeyR", shift: true }, - S: { key: "KeyS", shift: true }, - T: { key: "KeyT", shift: true }, - U: { key: "KeyU", shift: true }, - V: { key: "KeyV", shift: true }, - W: { key: "KeyW", shift: true }, - X: { key: "KeyX", shift: true }, - Y: { key: "KeyY", shift: true }, - Z: { key: "KeyZ", shift: true }, - a: { key: "KeyA" }, - b: { key: "KeyB" }, - c: { key: "KeyC" }, - d: { key: "KeyD" }, - e: { key: "KeyE" }, - f: { key: "KeyF" }, - g: { key: "KeyG" }, - h: { key: "KeyH" }, - i: { key: "KeyI" }, - j: { key: "KeyJ" }, - k: { key: "KeyK" }, - l: { key: "KeyL" }, - m: { key: "KeyM" }, - n: { key: "KeyN" }, - o: { key: "KeyO" }, - p: { key: "KeyP" }, - q: { key: "KeyQ" }, - r: { key: "KeyR" }, - s: { key: "KeyS" }, - t: { key: "KeyT" }, - u: { key: "KeyU" }, - v: { key: "KeyV" }, - w: { key: "KeyW" }, - x: { key: "KeyX" }, - y: { key: "KeyY" }, - z: { key: "KeyZ" }, - 1: { key: "Digit1" }, - "!": { key: "Digit1", shift: true }, - 2: { key: "Digit2" }, - '"': { key: "Digit2", shift: true }, - 3: { key: "Digit3" }, - "£": { key: "Digit3", shift: true }, - 4: { key: "Digit4" }, - $: { key: "Digit4", shift: true }, - "€": { key: "Digit4", altRight: true }, - 5: { key: "Digit5" }, - "%": { key: "Digit5", shift: true }, - 6: { key: "Digit6" }, - "^": { key: "Digit6", shift: true }, - 7: { key: "Digit7" }, - "&": { key: "Digit7", shift: true }, - 8: { key: "Digit8" }, - "*": { key: "Digit8", shift: true }, - 9: { key: "Digit9" }, - "(": { key: "Digit9", shift: true }, - 0: { key: "Digit0" }, - ")": { key: "Digit0", shift: true }, - "-": { key: "Minus" }, - _: { key: "Minus", shift: true }, - "=": { key: "Equal" }, - "+": { key: "Equal", shift: true }, - "'": { key: "Quote" }, - "@": { key: "Quote", shift: true }, - ",": { key: "Comma" }, - "<": { key: "Comma", shift: true }, - "/": { key: "Slash" }, - "?": { key: "Slash", shift: true }, - ".": { key: "Period" }, - ">": { key: "Period", shift: true }, - ";": { key: "Semicolon" }, - ":": { key: "Semicolon", shift: true }, - "[": { key: "BracketLeft" }, - "{": { key: "BracketLeft", shift: true }, - "]": { key: "BracketRight" }, - "}": { key: "BracketRight", shift: true }, - "#": { key: "Backslash" }, - "~": { key: "Backslash", shift: true }, - "`": { key: "Backquote" }, - "¬": { key: "Backquote", shift: true }, - "\\": { key: "IntlBackslash" }, - "|": { key: "IntlBackslash", shift: true }, - " ": { key: "Space" }, - "\n": { key: "Enter" }, - Enter: { key: "Enter" }, - Tab: { key: "Tab" }, -} as Record; - -export const en_UK: KeyboardLayout = { - isoCode: isoCode, - name: name, - chars: chars, - // TODO need to localize these maps and layouts - keyDisplayMap: en_US.keyDisplayMap, - modifierDisplayMap: en_US.modifierDisplayMap, - virtualKeyboard: en_US.virtualKeyboard, -}; diff --git a/ui/src/keyboardLayouts/en_US.ts b/ui/src/keyboardLayouts/en_US.ts deleted file mode 100644 index b272bd68f..000000000 --- a/ui/src/keyboardLayouts/en_US.ts +++ /dev/null @@ -1,381 +0,0 @@ -import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"; - -const name = "English (US)"; -const isoCode = "en-US"; - -// dead keys for "international" 101 keyboards TODO -/* -const keyAcute = { key: "Quote", control: true, menu: true, mark: "´" } // acute accent -const keyCedilla = { key: ".", shift: true, alt: true, mark: "¸" } // cedilla accent -const keyComma = { key: "BracketRight", shift: true, altRight: true, mark: "," } // comma accent -const keyDiaeresis = { key: "Quote", shift: true, control: true, menu: true, mark: "¨" } // diaeresis accent -const keyDegree = { key: "Semicolon", shift: true, control: true, menu: true, mark: "°" } // degree accent -*/ - -export const chars = { - A: { key: "KeyA", shift: true }, - B: { key: "KeyB", shift: true }, - C: { key: "KeyC", shift: true }, - D: { key: "KeyD", shift: true }, - E: { key: "KeyE", shift: true }, - F: { key: "KeyF", shift: true }, - G: { key: "KeyG", shift: true }, - H: { key: "KeyH", shift: true }, - I: { key: "KeyI", shift: true }, - J: { key: "KeyJ", shift: true }, - K: { key: "KeyK", shift: true }, - L: { key: "KeyL", shift: true }, - M: { key: "KeyM", shift: true }, - N: { key: "KeyN", shift: true }, - O: { key: "KeyO", shift: true }, - P: { key: "KeyP", shift: true }, - Q: { key: "KeyQ", shift: true }, - R: { key: "KeyR", shift: true }, - S: { key: "KeyS", shift: true }, - T: { key: "KeyT", shift: true }, - U: { key: "KeyU", shift: true }, - V: { key: "KeyV", shift: true }, - W: { key: "KeyW", shift: true }, - X: { key: "KeyX", shift: true }, - Y: { key: "KeyY", shift: true }, - Z: { key: "KeyZ", shift: true }, - a: { key: "KeyA" }, - b: { key: "KeyB" }, - c: { key: "KeyC" }, - d: { key: "KeyD" }, - e: { key: "KeyE" }, - f: { key: "KeyF" }, - g: { key: "KeyG" }, - h: { key: "KeyH" }, - i: { key: "KeyI" }, - j: { key: "KeyJ" }, - k: { key: "KeyK" }, - l: { key: "KeyL" }, - m: { key: "KeyM" }, - n: { key: "KeyN" }, - o: { key: "KeyO" }, - p: { key: "KeyP" }, - q: { key: "KeyQ" }, - r: { key: "KeyR" }, - s: { key: "KeyS" }, - t: { key: "KeyT" }, - u: { key: "KeyU" }, - v: { key: "KeyV" }, - w: { key: "KeyW" }, - x: { key: "KeyX" }, - y: { key: "KeyY" }, - z: { key: "KeyZ" }, - 1: { key: "Digit1" }, - "!": { key: "Digit1", shift: true }, - 2: { key: "Digit2" }, - "@": { key: "Digit2", shift: true }, - 3: { key: "Digit3" }, - "#": { key: "Digit3", shift: true }, - 4: { key: "Digit4" }, - $: { key: "Digit4", shift: true }, - "%": { key: "Digit5", shift: true }, - 5: { key: "Digit5" }, - "^": { key: "Digit6", shift: true }, - 6: { key: "Digit6" }, - "&": { key: "Digit7", shift: true }, - 7: { key: "Digit7" }, - "*": { key: "Digit8", shift: true }, - 8: { key: "Digit8" }, - "(": { key: "Digit9", shift: true }, - 9: { key: "Digit9" }, - ")": { key: "Digit0", shift: true }, - 0: { key: "Digit0" }, - "-": { key: "Minus" }, - _: { key: "Minus", shift: true }, - "=": { key: "Equal" }, - "+": { key: "Equal", shift: true }, - "'": { key: "Quote" }, - '"': { key: "Quote", shift: true }, - ",": { key: "Comma" }, - "<": { key: "Comma", shift: true }, - "/": { key: "Slash" }, - "?": { key: "Slash", shift: true }, - ".": { key: "Period" }, - ">": { key: "Period", shift: true }, - ";": { key: "Semicolon" }, - ":": { key: "Semicolon", shift: true }, - "¶": { key: "Semicolon", altRight: true }, // pilcrow sign - "[": { key: "BracketLeft" }, - "{": { key: "BracketLeft", shift: true }, - "«": { key: "BracketLeft", altRight: true }, // double left quote sign - "]": { key: "BracketRight" }, - "}": { key: "BracketRight", shift: true }, - "»": { key: "BracketRight", altRight: true }, // double right quote sign - "\\": { key: "Backslash" }, - "|": { key: "Backslash", shift: true }, - "¬": { key: "Backslash", altRight: true }, // not sign - "`": { key: "Backquote" }, - "~": { key: "Backquote", shift: true }, - "§": { key: "IntlBackslash" }, - "±": { key: "IntlBackslash", shift: true }, - " ": { key: "Space" }, - "\n": { key: "Enter" }, - Enter: { key: "Enter" }, - Escape: { key: "Escape" }, - Tab: { key: "Tab" }, - PrintScreen: { key: "Prt Sc" }, - SystemRequest: { key: "Prt Sc", shift: true }, - ScrollLock: { key: "ScrollLock" }, - Pause: { key: "Pause" }, - Break: { key: "Pause", shift: true }, - Insert: { key: "Insert" }, - Delete: { key: "Delete" }, -} as Record; - -export const modifierDisplayMap: Record = { - ControlLeft: "Left Ctrl", - ControlRight: "Right Ctrl", - ShiftLeft: "Left Shift", - ShiftRight: "Right Shift", - AltLeft: "Left Alt", - AltRight: "Right Alt", - MetaLeft: "Left Meta", - MetaRight: "Right Meta", - AltGr: "AltGr", -} as Record; - -export const keyDisplayMap: Record = { - CtrlAltDelete: "Ctrl + Alt + Delete", - AltMetaEscape: "Alt + Meta + Escape", - CtrlAltBackspace: "Ctrl + Alt + Backspace", - AltGr: "AltGr", - AltLeft: "Alt ⌥", - AltRight: "⌥ Alt", - ArrowDown: "↓", - ArrowLeft: "←", - ArrowRight: "→", - ArrowUp: "↑", - Backspace: "Backspace", - "(Backspace)": "Backspace", - CapsLock: "Caps Lock ⇪", - Clear: "Clear", - ControlLeft: "Ctrl ⌃", - ControlRight: "⌃ Ctrl", - Delete: "Delete ⌦", - End: "End", - Enter: "Enter", - Escape: "Esc", - Home: "Home", - Insert: "Insert", - Menu: "Menu", - MetaLeft: "Meta ⌘", - MetaRight: "⌘ Meta", - PageDown: "PgDn", - PageUp: "PgUp", - ShiftLeft: "Shift ⇧", - ShiftRight: "⇧ Shift", - Space: " ", - Tab: "Tab ⇥", - - // Letters - KeyA: "a", - KeyB: "b", - KeyC: "c", - KeyD: "d", - KeyE: "e", - KeyF: "f", - KeyG: "g", - KeyH: "h", - KeyI: "i", - KeyJ: "j", - KeyK: "k", - KeyL: "l", - KeyM: "m", - KeyN: "n", - KeyO: "o", - KeyP: "p", - KeyQ: "q", - KeyR: "r", - KeyS: "s", - KeyT: "t", - KeyU: "u", - KeyV: "v", - KeyW: "w", - KeyX: "x", - KeyY: "y", - KeyZ: "z", - - // Capital letters - "(KeyA)": "A", - "(KeyB)": "B", - "(KeyC)": "C", - "(KeyD)": "D", - "(KeyE)": "E", - "(KeyF)": "F", - "(KeyG)": "G", - "(KeyH)": "H", - "(KeyI)": "I", - "(KeyJ)": "J", - "(KeyK)": "K", - "(KeyL)": "L", - "(KeyM)": "M", - "(KeyN)": "N", - "(KeyO)": "O", - "(KeyP)": "P", - "(KeyQ)": "Q", - "(KeyR)": "R", - "(KeyS)": "S", - "(KeyT)": "T", - "(KeyU)": "U", - "(KeyV)": "V", - "(KeyW)": "W", - "(KeyX)": "X", - "(KeyY)": "Y", - "(KeyZ)": "Z", - - // Numbers - Digit1: "1", - Digit2: "2", - Digit3: "3", - Digit4: "4", - Digit5: "5", - Digit6: "6", - Digit7: "7", - Digit8: "8", - Digit9: "9", - Digit0: "0", - - // Shifted Numbers - "(Digit1)": "!", - "(Digit2)": "@", - "(Digit3)": "#", - "(Digit4)": "$", - "(Digit5)": "%", - "(Digit6)": "^", - "(Digit7)": "&", - "(Digit8)": "*", - "(Digit9)": "(", - "(Digit0)": ")", - - // Symbols - Minus: "-", - "(Minus)": "_", - Equal: "=", - "(Equal)": "+", - BracketLeft: "[", - "(BracketLeft)": "{", - BracketRight: "]", - "(BracketRight)": "}", - Backslash: "\\", - "(Backslash)": "|", - Semicolon: ";", - "(Semicolon)": ":", - Quote: "'", - "(Quote)": '"', - Comma: ",", - "(Comma)": "<", - Period: ".", - "(Period)": ">", - Slash: "/", - "(Slash)": "?", - Backquote: "`", - "(Backquote)": "~", - IntlBackslash: "\\", - - // Function keys - F1: "F1", - F2: "F2", - F3: "F3", - F4: "F4", - F5: "F5", - F6: "F6", - F7: "F7", - F8: "F8", - F9: "F9", - F10: "F10", - F11: "F11", - F12: "F12", - - // Numpad - Numpad0: "Num 0", - Numpad1: "Num 1", - Numpad2: "Num 2", - Numpad3: "Num 3", - Numpad4: "Num 4", - Numpad5: "Num 5", - Numpad6: "Num 6", - Numpad7: "Num 7", - Numpad8: "Num 8", - Numpad9: "Num 9", - NumpadAdd: "Num +", - NumpadSubtract: "Num -", - NumpadMultiply: "Num *", - NumpadDivide: "Num /", - NumpadDecimal: "Num .", - NumpadEqual: "Num =", - NumpadEnter: "Num Enter", - NumpadInsert: "Ins", - NumpadDelete: "Del", - NumLock: "Num Lock", - - // Modals - PrintScreen: "Prt Sc", - ScrollLock: "Scr Lk", - Pause: "Pause", - "(PrintScreen)": "Sys Rq", - "(Pause)": "Break", - SystemRequest: "Sys Rq", - Break: "Break", -}; - -export const virtualKeyboard = { - main: { - default: [ - "CtrlAltDelete AltMetaEscape CtrlAltBackspace", - "Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12", - "Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace", - "Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash", - "CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter", - "ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight", - "ControlLeft MetaLeft AltLeft Space AltGr MetaRight Menu ControlRight", - ], - shift: [ - "CtrlAltDelete AltMetaEscape CtrlAltBackspace", - "Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12", - "(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Backspace)", - "Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)", - "CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter", - "ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight", - "ControlLeft MetaLeft AltLeft Space AltGr MetaRight Menu ControlRight", - ], - }, - control: { - default: ["PrintScreen ScrollLock Pause", "Insert Home PageUp", "Delete End PageDown"], - shift: ["(PrintScreen) ScrollLock (Pause)", "Insert Home PageUp", "Delete End PageDown"], - }, - - arrows: { - default: ["ArrowUp", "ArrowLeft ArrowDown ArrowRight"], - }, - - numpad: { - numlocked: [ - "NumLock NumpadDivide NumpadMultiply NumpadSubtract", - "Numpad7 Numpad8 Numpad9 NumpadAdd", - "Numpad4 Numpad5 Numpad6", - "Numpad1 Numpad2 Numpad3 NumpadEnter", - "Numpad0 NumpadDecimal", - ], - default: [ - "NumLock NumpadDivide NumpadMultiply NumpadSubtract", - "Home ArrowUp PageUp NumpadAdd", - "ArrowLeft Clear ArrowRight", - "End ArrowDown PageDown NumpadEnter", - "NumpadInsert NumpadDelete", - ], - }, -}; - -export const en_US: KeyboardLayout = { - isoCode, - name, - chars, - keyDisplayMap, - modifierDisplayMap, - virtualKeyboard, -}; diff --git a/ui/src/keyboardLayouts/es_ES.ts b/ui/src/keyboardLayouts/es_ES.ts deleted file mode 100644 index f5014184e..000000000 --- a/ui/src/keyboardLayouts/es_ES.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"; - -import { en_US } from "./en_US"; // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard - -const name = "Español"; -const isoCode = "es-ES"; - -const keyTrema: KeyCombo = { key: "Quote", shift: true }; // tréma (umlaut), two dots placed above a vowel -const keyAcute: KeyCombo = { key: "Quote" }; // accent aigu (acute accent), mark ´ placed above the letter -const keyHat: KeyCombo = { key: "BracketRight", shift: true }; // accent circonflexe (accent hat), mark ^ placed above the letter -const keyGrave: KeyCombo = { key: "BracketRight" }; // accent grave, mark ` placed above the letter -const keyTilde: KeyCombo = { key: "Key4", altRight: true }; // tilde, mark ~ placed above the letter - -const chars = { - A: { key: "KeyA", shift: true }, - Ä: { key: "KeyA", shift: true, accentKey: keyTrema }, - Á: { key: "KeyA", shift: true, accentKey: keyAcute }, - Â: { key: "KeyA", shift: true, accentKey: keyHat }, - À: { key: "KeyA", shift: true, accentKey: keyGrave }, - Ã: { key: "KeyA", shift: true, accentKey: keyTilde }, - B: { key: "KeyB", shift: true }, - C: { key: "KeyC", shift: true }, - D: { key: "KeyD", shift: true }, - E: { key: "KeyE", shift: true }, - Ë: { key: "KeyE", shift: true, accentKey: keyTrema }, - É: { key: "KeyE", shift: true, accentKey: keyAcute }, - Ê: { key: "KeyE", shift: true, accentKey: keyHat }, - È: { key: "KeyE", shift: true, accentKey: keyGrave }, - Ẽ: { key: "KeyE", shift: true, accentKey: keyTilde }, - F: { key: "KeyF", shift: true }, - G: { key: "KeyG", shift: true }, - H: { key: "KeyH", shift: true }, - I: { key: "KeyI", shift: true }, - Ï: { key: "KeyI", shift: true, accentKey: keyTrema }, - Í: { key: "KeyI", shift: true, accentKey: keyAcute }, - Î: { key: "KeyI", shift: true, accentKey: keyHat }, - Ì: { key: "KeyI", shift: true, accentKey: keyGrave }, - Ĩ: { key: "KeyI", shift: true, accentKey: keyTilde }, - J: { key: "KeyJ", shift: true }, - K: { key: "KeyK", shift: true }, - L: { key: "KeyL", shift: true }, - M: { key: "KeyM", shift: true }, - N: { key: "KeyN", shift: true }, - O: { key: "KeyO", shift: true }, - Ö: { key: "KeyO", shift: true, accentKey: keyTrema }, - Ó: { key: "KeyO", shift: true, accentKey: keyAcute }, - Ô: { key: "KeyO", shift: true, accentKey: keyHat }, - Ò: { key: "KeyO", shift: true, accentKey: keyGrave }, - Õ: { key: "KeyO", shift: true, accentKey: keyTilde }, - P: { key: "KeyP", shift: true }, - Q: { key: "KeyQ", shift: true }, - R: { key: "KeyR", shift: true }, - S: { key: "KeyS", shift: true }, - T: { key: "KeyT", shift: true }, - U: { key: "KeyU", shift: true }, - Ü: { key: "KeyU", shift: true, accentKey: keyTrema }, - Ú: { key: "KeyU", shift: true, accentKey: keyAcute }, - Û: { key: "KeyU", shift: true, accentKey: keyHat }, - Ù: { key: "KeyU", shift: true, accentKey: keyGrave }, - Ũ: { key: "KeyU", shift: true, accentKey: keyTilde }, - V: { key: "KeyV", shift: true }, - W: { key: "KeyW", shift: true }, - X: { key: "KeyX", shift: true }, - Y: { key: "KeyY", shift: true }, - Z: { key: "KeyZ", shift: true }, - a: { key: "KeyA" }, - ä: { key: "KeyA", accentKey: keyTrema }, - á: { key: "KeyA", accentKey: keyAcute }, - â: { key: "KeyA", accentKey: keyHat }, - à: { key: "KeyA", accentKey: keyGrave }, - ã: { key: "KeyA", accentKey: keyTilde }, - b: { key: "KeyB" }, - c: { key: "KeyC" }, - d: { key: "KeyD" }, - e: { key: "KeyE" }, - ë: { key: "KeyE", accentKey: keyTrema }, - é: { key: "KeyE", accentKey: keyAcute }, - ê: { key: "KeyE", accentKey: keyHat }, - è: { key: "KeyE", accentKey: keyGrave }, - ẽ: { key: "KeyE", accentKey: keyTilde }, - "€": { key: "KeyE", altRight: true }, - f: { key: "KeyF" }, - g: { key: "KeyG" }, - h: { key: "KeyH" }, - i: { key: "KeyI" }, - ï: { key: "KeyI", accentKey: keyTrema }, - í: { key: "KeyI", accentKey: keyAcute }, - î: { key: "KeyI", accentKey: keyHat }, - ì: { key: "KeyI", accentKey: keyGrave }, - ĩ: { key: "KeyI", accentKey: keyTilde }, - j: { key: "KeyJ" }, - k: { key: "KeyK" }, - l: { key: "KeyL" }, - m: { key: "KeyM" }, - n: { key: "KeyN" }, - o: { key: "KeyO" }, - ö: { key: "KeyO", accentKey: keyTrema }, - ó: { key: "KeyO", accentKey: keyAcute }, - ô: { key: "KeyO", accentKey: keyHat }, - ò: { key: "KeyO", accentKey: keyGrave }, - õ: { key: "KeyO", accentKey: keyTilde }, - p: { key: "KeyP" }, - q: { key: "KeyQ" }, - r: { key: "KeyR" }, - s: { key: "KeyS" }, - t: { key: "KeyT" }, - u: { key: "KeyU" }, - ü: { key: "KeyU", accentKey: keyTrema }, - ú: { key: "KeyU", accentKey: keyAcute }, - û: { key: "KeyU", accentKey: keyHat }, - ù: { key: "KeyU", accentKey: keyGrave }, - ũ: { key: "KeyU", accentKey: keyTilde }, - v: { key: "KeyV" }, - w: { key: "KeyW" }, - x: { key: "KeyX" }, - y: { key: "KeyY" }, - z: { key: "KeyZ" }, - º: { key: "Backquote" }, - ª: { key: "Backquote", shift: true }, - "\\": { key: "Backquote", altRight: true }, - 1: { key: "Digit1" }, - "!": { key: "Digit1", shift: true }, - "|": { key: "Digit1", altRight: true }, - 2: { key: "Digit2" }, - '"': { key: "Digit2", shift: true }, - "@": { key: "Digit2", altRight: true }, - 3: { key: "Digit3" }, - "·": { key: "Digit3", shift: true }, - "#": { key: "Digit3", altRight: true }, - 4: { key: "Digit4" }, - $: { key: "Digit4", shift: true }, - 5: { key: "Digit5" }, - "%": { key: "Digit5", shift: true }, - 6: { key: "Digit6" }, - "&": { key: "Digit6", shift: true }, - "¬": { key: "Digit6", altRight: true }, - 7: { key: "Digit7" }, - "/": { key: "Digit7", shift: true }, - 8: { key: "Digit8" }, - "(": { key: "Digit8", shift: true }, - 9: { key: "Digit9" }, - ")": { key: "Digit9", shift: true }, - 0: { key: "Digit0" }, - "=": { key: "Digit0", shift: true }, - "'": { key: "Minus" }, - "?": { key: "Minus", shift: true }, - "¡": { key: "Equal", deadKey: true }, - "¿": { key: "Equal", shift: true }, - "[": { key: "BracketLeft", altRight: true }, - "+": { key: "BracketRight" }, - "*": { key: "BracketRight", shift: true }, - "]": { key: "BracketRight", altRight: true }, - ñ: { key: "Semicolon" }, - Ñ: { key: "Semicolon", shift: true }, - "{": { key: "Quote", altRight: true }, - ç: { key: "Backslash" }, - Ç: { key: "Backslash", shift: true }, - "}": { key: "Backslash", altRight: true }, - ",": { key: "Comma" }, - ";": { key: "Comma", shift: true }, - ".": { key: "Period" }, - ":": { key: "Period", shift: true }, - "-": { key: "Slash" }, - _: { key: "Slash", shift: true }, - "<": { key: "IntlBackslash" }, - ">": { key: "IntlBackslash", shift: true }, - " ": { key: "Space" }, - "\n": { key: "Enter" }, - Enter: { key: "Enter" }, - Tab: { key: "Tab" }, -} as Record; - -export const es_ES: KeyboardLayout = { - isoCode: isoCode, - name: name, - chars: chars, - // TODO need to localize these maps and layouts - keyDisplayMap: en_US.keyDisplayMap, - modifierDisplayMap: en_US.modifierDisplayMap, - virtualKeyboard: en_US.virtualKeyboard, -}; diff --git a/ui/src/keyboardLayouts/fr_BE.ts b/ui/src/keyboardLayouts/fr_BE.ts deleted file mode 100644 index 9eb0fef00..000000000 --- a/ui/src/keyboardLayouts/fr_BE.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"; - -import { en_US } from "./en_US"; // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard - -const name = "Belgisch Nederlands"; -const isoCode = "nl-BE"; - -const keyTrema: KeyCombo = { key: "BracketLeft", shift: true }; // tréma (umlaut), two dots placed above a vowel -const keyHat: KeyCombo = { key: "BracketLeft" }; // accent circonflexe (accent hat), mark ^ placed above the letter -const keyAcute: KeyCombo = { key: "Semicolon", altRight: true }; // accent aigu (acute accent), mark ´ placed above the letter -const keyGrave: KeyCombo = { key: "Quote", shift: true }; // accent grave, mark ` placed above the letter -const keyTilde: KeyCombo = { key: "Slash", altRight: true }; // tilde, mark ~ placed above the letter - -const chars = { - A: { key: "KeyQ", shift: true }, - Ä: { key: "KeyQ", shift: true, accentKey: keyTrema }, - Â: { key: "KeyQ", shift: true, accentKey: keyHat }, - Á: { key: "KeyQ", shift: true, accentKey: keyAcute }, - À: { key: "KeyQ", shift: true, accentKey: keyGrave }, - Ã: { key: "KeyQ", shift: true, accentKey: keyTilde }, - B: { key: "KeyB", shift: true }, - C: { key: "KeyC", shift: true }, - D: { key: "KeyD", shift: true }, - E: { key: "KeyE", shift: true }, - Ë: { key: "KeyE", shift: true, accentKey: keyTrema }, - Ê: { key: "KeyE", shift: true, accentKey: keyHat }, - É: { key: "KeyE", shift: true, accentKey: keyAcute }, - È: { key: "KeyE", shift: true, accentKey: keyGrave }, - Ẽ: { key: "KeyE", shift: true, accentKey: keyTilde }, - F: { key: "KeyF", shift: true }, - G: { key: "KeyG", shift: true }, - H: { key: "KeyH", shift: true }, - I: { key: "KeyI", shift: true }, - Ï: { key: "KeyI", shift: true, accentKey: keyTrema }, - Î: { key: "KeyI", shift: true, accentKey: keyHat }, - Í: { key: "KeyI", shift: true, accentKey: keyAcute }, - Ì: { key: "KeyI", shift: true, accentKey: keyGrave }, - Ĩ: { key: "KeyI", shift: true, accentKey: keyTilde }, - J: { key: "KeyJ", shift: true }, - K: { key: "KeyK", shift: true }, - L: { key: "KeyL", shift: true }, - M: { key: "Semicolon", shift: true }, - N: { key: "KeyN", shift: true }, - O: { key: "KeyO", shift: true }, - Ö: { key: "KeyO", shift: true, accentKey: keyTrema }, - Ô: { key: "KeyO", shift: true, accentKey: keyHat }, - Ó: { key: "KeyO", shift: true, accentKey: keyAcute }, - Ò: { key: "KeyO", shift: true, accentKey: keyGrave }, - Õ: { key: "KeyO", shift: true, accentKey: keyTilde }, - P: { key: "KeyP", shift: true }, - Q: { key: "KeyA", shift: true }, - R: { key: "KeyR", shift: true }, - S: { key: "KeyS", shift: true }, - T: { key: "KeyT", shift: true }, - U: { key: "KeyU", shift: true }, - Ü: { key: "KeyU", shift: true, accentKey: keyTrema }, - Û: { key: "KeyU", shift: true, accentKey: keyHat }, - Ú: { key: "KeyU", shift: true, accentKey: keyAcute }, - Ù: { key: "KeyU", shift: true, accentKey: keyGrave }, - Ũ: { key: "KeyU", shift: true, accentKey: keyTilde }, - V: { key: "KeyV", shift: true }, - W: { key: "KeyW", shift: true }, - X: { key: "KeyX", shift: true }, - Y: { key: "KeyZ", shift: true }, - Z: { key: "KeyY", shift: true }, - a: { key: "KeyQ" }, - ä: { key: "KeyQ", accentKey: keyTrema }, - â: { key: "KeyQ", accentKey: keyHat }, - á: { key: "KeyQ", accentKey: keyAcute }, - ã: { key: "KeyQ", accentKey: keyTilde }, - b: { key: "KeyB" }, - c: { key: "KeyC" }, - d: { key: "KeyD" }, - e: { key: "KeyE" }, - ë: { key: "KeyE", accentKey: keyTrema }, - ê: { key: "KeyE", accentKey: keyHat }, - ẽ: { key: "KeyE", accentKey: keyTilde }, - "€": { key: "KeyE", altRight: true }, - f: { key: "KeyF" }, - g: { key: "KeyG" }, - h: { key: "KeyH" }, - i: { key: "KeyI" }, - ï: { key: "KeyI", accentKey: keyTrema }, - î: { key: "KeyI", accentKey: keyHat }, - í: { key: "KeyI", accentKey: keyAcute }, - ì: { key: "KeyI", accentKey: keyGrave }, - ĩ: { key: "KeyI", accentKey: keyTilde }, - j: { key: "KeyJ" }, - k: { key: "KeyK" }, - l: { key: "KeyL" }, - m: { key: "Semicolon" }, - n: { key: "KeyN" }, - o: { key: "KeyO" }, - ö: { key: "KeyO", accentKey: keyTrema }, - ó: { key: "KeyO", accentKey: keyAcute }, - ô: { key: "KeyO", accentKey: keyHat }, - ò: { key: "KeyO", accentKey: keyGrave }, - õ: { key: "KeyO", accentKey: keyTilde }, - p: { key: "KeyP" }, - q: { key: "KeyA" }, - r: { key: "KeyR" }, - s: { key: "KeyS" }, - t: { key: "KeyT" }, - u: { key: "KeyU" }, - ü: { key: "KeyU", accentKey: keyTrema }, - û: { key: "KeyU", accentKey: keyHat }, - ú: { key: "KeyU", accentKey: keyAcute }, - ũ: { key: "KeyU", accentKey: keyTilde }, - v: { key: "KeyV" }, - w: { key: "KeyW" }, - x: { key: "KeyX" }, - y: { key: "KeyZ" }, - z: { key: "KeyY" }, - "²": { key: "Backquote" }, - "³": { key: "Backquote", shift: true }, - "&": { key: "Digit1" }, - 1: { key: "Digit1", shift: true }, - "|": { key: "Digit1", altRight: true }, - é: { key: "Digit2" }, - 2: { key: "Digit2", shift: true }, - "@": { key: "Digit2", altRight: true }, - '"': { key: "Digit3" }, - 3: { key: "Digit3", shift: true }, - "#": { key: "Digit3", altRight: true }, - "'": { key: "Digit4" }, - 4: { key: "Digit4", shift: true }, - "(": { key: "Digit5" }, - 5: { key: "Digit5", shift: true }, - "§": { key: "Digit6" }, - 6: { key: "Digit6", shift: true }, - "^": { key: "Digit6", altRight: true }, - è: { key: "Digit7" }, - 7: { key: "Digit7", shift: true }, - "!": { key: "Digit8" }, - 8: { key: "Digit8", shift: true }, - ç: { key: "Digit9" }, - 9: { key: "Digit9", shift: true }, - "{": { key: "Digit9", altRight: true }, - à: { key: "Digit0" }, - 0: { key: "Digit0", shift: true }, - "}": { key: "Digit0", altRight: true }, - ")": { key: "Minus" }, - "°": { key: "Minus", shift: true }, - "-": { key: "Equal", deadKey: true }, - _: { key: "Equal", shift: true }, - "[": { key: "BracketLeft", altRight: true }, - $: { key: "BracketRight" }, - "*": { key: "BracketRight", altRight: true }, - "]": { key: "BracketRight", altRight: true }, - ù: { key: "Quote" }, - "%": { key: "Quote", shift: true }, - µ: { key: "Backslash" }, - "£": { key: "Backslash", shift: true }, - ",": { key: "KeyM" }, - "?": { key: "KeyM", shift: true }, - ";": { key: "Comma" }, - ".": { key: "Comma", shift: true }, - ":": { key: "Period" }, - "/": { key: "Period", shift: true }, - "=": { key: "Slash" }, - "+": { key: "Slash", shift: true }, - "~": { key: "Slash", deadKey: true }, - "<": { key: "IntlBackslash" }, - ">": { key: "IntlBackslash", shift: true }, - "\\": { key: "IntlBackslash", altRight: true }, - " ": { key: "Space" }, - "\n": { key: "Enter" }, - Enter: { key: "Enter" }, - Tab: { key: "Tab" }, -} as Record; - -export const fr_BE: KeyboardLayout = { - isoCode: isoCode, - name: name, - chars: chars, - // TODO need to localize these maps and layouts - keyDisplayMap: en_US.keyDisplayMap, - modifierDisplayMap: en_US.modifierDisplayMap, - virtualKeyboard: en_US.virtualKeyboard, -}; diff --git a/ui/src/keyboardLayouts/fr_CH.ts b/ui/src/keyboardLayouts/fr_CH.ts deleted file mode 100644 index 319f2561e..000000000 --- a/ui/src/keyboardLayouts/fr_CH.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"; - -import { de_CH } from "./de_CH"; - -const name = "Français de Suisse"; -const isoCode = "fr-CH"; - -const chars = { - ...de_CH.chars, - è: { key: "BracketLeft" }, - ü: { key: "BracketLeft", shift: true }, - é: { key: "Semicolon" }, - ö: { key: "Semicolon", shift: true }, - à: { key: "Quote" }, - ä: { key: "Quote", shift: true }, -} as Record; - -const keyDisplayMap = { - ...de_CH.keyDisplayMap, - BracketLeft: "è", - BracketLeftShift: "ü", - Semicolon: "é", - SemicolonShift: "ö", - Quote: "à", - QuoteShift: "ä", -} as Record; - -export const fr_CH: KeyboardLayout = { - isoCode: isoCode, - name: name, - chars: chars, - keyDisplayMap: keyDisplayMap, - // TODO need to localize these maps and layouts - modifierDisplayMap: de_CH.modifierDisplayMap, - virtualKeyboard: de_CH.virtualKeyboard, -}; diff --git a/ui/src/keyboardLayouts/fr_FR.ts b/ui/src/keyboardLayouts/fr_FR.ts deleted file mode 100644 index 6f6d162ea..000000000 --- a/ui/src/keyboardLayouts/fr_FR.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"; - -import { en_US } from "./en_US"; // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard - -const name = "Français"; -const isoCode = "fr-FR"; - -const keyTrema: KeyCombo = { key: "BracketLeft", shift: true }; // tréma (umlaut), two dots placed above a vowel -const keyHat: KeyCombo = { key: "BracketLeft" }; // accent circonflexe (accent hat), mark ^ placed above the letter - -const chars = { - A: { key: "KeyQ", shift: true }, - Ä: { key: "KeyQ", shift: true, accentKey: keyTrema }, - Â: { key: "KeyQ", shift: true, accentKey: keyHat }, - B: { key: "KeyB", shift: true }, - C: { key: "KeyC", shift: true }, - D: { key: "KeyD", shift: true }, - E: { key: "KeyE", shift: true }, - Ë: { key: "KeyE", shift: true, accentKey: keyTrema }, - Ê: { key: "KeyE", shift: true, accentKey: keyHat }, - F: { key: "KeyF", shift: true }, - G: { key: "KeyG", shift: true }, - H: { key: "KeyH", shift: true }, - I: { key: "KeyI", shift: true }, - Ï: { key: "KeyI", shift: true, accentKey: keyTrema }, - Î: { key: "KeyI", shift: true, accentKey: keyHat }, - J: { key: "KeyJ", shift: true }, - K: { key: "KeyK", shift: true }, - L: { key: "KeyL", shift: true }, - M: { key: "Semicolon", shift: true }, - N: { key: "KeyN", shift: true }, - O: { key: "KeyO", shift: true }, - Ö: { key: "KeyO", shift: true, accentKey: keyTrema }, - Ô: { key: "KeyO", shift: true, accentKey: keyHat }, - P: { key: "KeyP", shift: true }, - Q: { key: "KeyA", shift: true }, - R: { key: "KeyR", shift: true }, - S: { key: "KeyS", shift: true }, - T: { key: "KeyT", shift: true }, - U: { key: "KeyU", shift: true }, - Ü: { key: "KeyU", shift: true, accentKey: keyTrema }, - Û: { key: "KeyU", shift: true, accentKey: keyHat }, - V: { key: "KeyV", shift: true }, - W: { key: "KeyZ", shift: true }, - X: { key: "KeyX", shift: true }, - Y: { key: "KeyY", shift: true }, - Z: { key: "KeyW", shift: true }, - a: { key: "KeyQ" }, - ä: { key: "KeyQ", accentKey: keyTrema }, - â: { key: "KeyQ", accentKey: keyHat }, - b: { key: "KeyB" }, - c: { key: "KeyC" }, - d: { key: "KeyD" }, - e: { key: "KeyE" }, - ë: { key: "KeyE", accentKey: keyTrema }, - ê: { key: "KeyE", accentKey: keyHat }, - "€": { key: "KeyE", altRight: true }, - f: { key: "KeyF" }, - g: { key: "KeyG" }, - h: { key: "KeyH" }, - i: { key: "KeyI" }, - ï: { key: "KeyI", accentKey: keyTrema }, - î: { key: "KeyI", accentKey: keyHat }, - j: { key: "KeyJ" }, - k: { key: "KeyK" }, - l: { key: "KeyL" }, - m: { key: "Semicolon" }, - n: { key: "KeyN" }, - o: { key: "KeyO" }, - ö: { key: "KeyO", accentKey: keyTrema }, - ô: { key: "KeyO", accentKey: keyHat }, - p: { key: "KeyP" }, - q: { key: "KeyA" }, - r: { key: "KeyR" }, - s: { key: "KeyS" }, - t: { key: "KeyT" }, - u: { key: "KeyU" }, - ü: { key: "KeyU", accentKey: keyTrema }, - û: { key: "KeyU", accentKey: keyHat }, - v: { key: "KeyV" }, - w: { key: "KeyZ" }, - x: { key: "KeyX" }, - y: { key: "KeyY" }, - z: { key: "KeyW" }, - "²": { key: "Backquote" }, - "&": { key: "Digit1" }, - 1: { key: "Digit1", shift: true }, - é: { key: "Digit2" }, - 2: { key: "Digit2", shift: true }, - "~": { key: "Digit2", altRight: true }, - '"': { key: "Digit3" }, - 3: { key: "Digit3", shift: true }, - "#": { key: "Digit3", altRight: true }, - "'": { key: "Digit4" }, - 4: { key: "Digit4", shift: true }, - "{": { key: "Digit4", altRight: true }, - "(": { key: "Digit5" }, - 5: { key: "Digit5", shift: true }, - "[": { key: "Digit5", altRight: true }, - "-": { key: "Digit6" }, - 6: { key: "Digit6", shift: true }, - "|": { key: "Digit6", altRight: true }, - è: { key: "Digit7" }, - 7: { key: "Digit7", shift: true }, - "`": { key: "Digit7", altRight: true }, - _: { key: "Digit8" }, - 8: { key: "Digit8", shift: true }, - "\\": { key: "Digit8", altRight: true }, - ç: { key: "Digit9" }, - 9: { key: "Digit9", shift: true }, - "^": { key: "Digit9", altRight: true }, - à: { key: "Digit0" }, - 0: { key: "Digit0", shift: true }, - "@": { key: "Digit0", altRight: true }, - ")": { key: "Minus" }, - "°": { key: "Minus", shift: true }, - "]": { key: "Minus", altRight: true }, - "=": { key: "Equal" }, - "+": { key: "Equal", shift: true }, - "}": { key: "Equal", altRight: true }, - $: { key: "BracketRight" }, - "£": { key: "BracketRight", shift: true }, - "¤": { key: "BracketRight", altRight: true }, - ù: { key: "Quote" }, - "%": { key: "Quote", shift: true }, - "*": { key: "Backslash" }, - µ: { key: "Backslash", shift: true }, - ",": { key: "KeyM" }, - "?": { key: "KeyM", shift: true }, - ";": { key: "Comma" }, - ".": { key: "Comma", shift: true }, - ":": { key: "Period" }, - "/": { key: "Period", shift: true }, - "!": { key: "Slash" }, - "§": { key: "Slash", shift: true }, - "<": { key: "IntlBackslash" }, - ">": { key: "IntlBackslash", shift: true }, - " ": { key: "Space" }, - "\n": { key: "Enter" }, - Enter: { key: "Enter" }, - Tab: { key: "Tab" }, -} as Record; - -export const fr_FR: KeyboardLayout = { - isoCode: isoCode, - name: name, - chars: chars, - // TODO need to localize these maps and layouts - keyDisplayMap: en_US.keyDisplayMap, - modifierDisplayMap: en_US.modifierDisplayMap, - virtualKeyboard: en_US.virtualKeyboard, -}; diff --git a/ui/src/keyboardLayouts/hu_HU.ts b/ui/src/keyboardLayouts/hu_HU.ts deleted file mode 100644 index 99dc66282..000000000 --- a/ui/src/keyboardLayouts/hu_HU.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"; -import { en_US } from "./en_US"; - -const name = "Magyar"; -const isoCode = "hu-HU"; - -const keyAcute: KeyCombo = { key: "Digit9", altRight: true }; -const keyDoubleAcute: KeyCombo = { key: "Equal", shift: true }; -const keyTrema: KeyCombo = { key: "Equal", altRight: true }; - -const chars = { - A: { key: "KeyA", shift: true }, - Á: { key: "Semicolon", shift: true, accentKey: keyAcute }, - B: { key: "KeyB", shift: true }, - C: { key: "KeyC", shift: true }, - D: { key: "KeyD", shift: true }, - E: { key: "KeyE", shift: true }, - É: { key: "Quote", shift: true, accentKey: keyAcute }, - F: { key: "KeyF", shift: true }, - G: { key: "KeyG", shift: true }, - H: { key: "KeyH", shift: true }, - I: { key: "KeyI", shift: true }, - Í: { key: "IntlBackslash", shift: true, accentKey: keyAcute }, - J: { key: "KeyJ", shift: true }, - K: { key: "KeyK", shift: true }, - L: { key: "KeyL", shift: true }, - M: { key: "KeyM", shift: true }, - N: { key: "KeyN", shift: true }, - O: { key: "KeyO", shift: true }, - Ó: { key: "BracketLeft", shift: true, accentKey: keyAcute }, - Ö: { key: "Minus", shift: true, accentKey: keyTrema }, - Ő: { key: "BracketRight", shift: true, accentKey: keyDoubleAcute }, - P: { key: "KeyP", shift: true }, - Q: { key: "KeyQ", shift: true }, - R: { key: "KeyR", shift: true }, - S: { key: "KeyS", shift: true }, - T: { key: "KeyT", shift: true }, - U: { key: "KeyU", shift: true }, - Ú: { key: "Backslash", shift: true, accentKey: keyAcute }, - Ü: { key: "Equal", shift: true, accentKey: keyTrema }, - Ű: { key: "Backquote", shift: true, accentKey: keyDoubleAcute }, - V: { key: "KeyV", shift: true }, - W: { key: "KeyW", shift: true }, - X: { key: "KeyX", shift: true }, - Y: { key: "KeyZ", shift: true }, - Z: { key: "KeyY", shift: true }, - a: { key: "KeyA" }, - á: { key: "Semicolon", accentKey: keyAcute }, - b: { key: "KeyB" }, - c: { key: "KeyC" }, - d: { key: "KeyD" }, - e: { key: "KeyE" }, - é: { key: "Quote", accentKey: keyAcute }, - f: { key: "KeyF" }, - g: { key: "KeyG" }, - h: { key: "KeyH" }, - i: { key: "KeyI" }, - í: { key: "IntlBackslash", accentKey: keyAcute }, - j: { key: "KeyJ" }, - k: { key: "KeyK" }, - l: { key: "KeyL" }, - m: { key: "KeyM" }, - n: { key: "KeyN" }, - o: { key: "KeyO" }, - ó: { key: "BracketLeft", accentKey: keyAcute }, - ö: { key: "Minus", accentKey: keyTrema }, - ő: { key: "BracketRight", accentKey: keyDoubleAcute }, - p: { key: "KeyP" }, - q: { key: "KeyQ" }, - r: { key: "KeyR" }, - s: { key: "KeyS" }, - t: { key: "KeyT" }, - u: { key: "KeyU" }, - ú: { key: "Backslash", accentKey: keyAcute }, - ü: { key: "Equal", accentKey: keyTrema }, - ű: { key: "Backquote", accentKey: keyDoubleAcute }, - v: { key: "KeyV" }, - w: { key: "KeyW" }, - x: { key: "KeyX" }, - y: { key: "KeyZ" }, - z: { key: "KeyY" }, - - // Numbers and top row symbols - 0: { key: "Digit0" }, - "§": { key: "Digit0", shift: true }, - 1: { key: "Digit1" }, - "'": { key: "Digit1", shift: true }, - 2: { key: "Digit2" }, - '"': { key: "Digit2", shift: true }, - 3: { key: "Digit3" }, - "+": { key: "Digit3", shift: true }, - 4: { key: "Digit4" }, - "!": { key: "Digit4", shift: true }, - 5: { key: "Digit5" }, - "%": { key: "Digit5", shift: true }, - 6: { key: "Digit6" }, - "/": { key: "Digit6", shift: true }, - 7: { key: "Digit7" }, - "=": { key: "Digit7", shift: true }, - 8: { key: "Digit8" }, - "(": { key: "Digit8", shift: true }, - 9: { key: "Digit9" }, - ")": { key: "Digit9", shift: true }, - - // AltGr symbols - "~": { key: "Digit1", altRight: true }, - ˇ: { key: "Digit2", altRight: true }, - "^": { key: "Digit3", altRight: true }, - "˘": { key: "Digit4", altRight: true }, - "°": { key: "Digit5", altRight: true }, - "˛": { key: "Digit6", altRight: true }, - "`": { key: "Digit7", altRight: true }, - "˙": { key: "Digit8", altRight: true }, - "´": { key: "Digit9", altRight: true }, - "˝": { key: "Digit0", altRight: true }, - "„": { key: "KeyO", altRight: true }, - "\\": { key: "KeyQ", altRight: true }, - "|": { key: "KeyW", altRight: true }, - "€": { key: "KeyU", altRight: true }, - đ: { key: "KeyS", altRight: true }, - "[": { key: "KeyF", altRight: true }, - "]": { key: "KeyG", altRight: true }, - ß: { key: "Semicolon", altRight: true }, - $: { key: "Quote", altRight: true }, - "¤": { key: "Backquote", altRight: true }, - "@": { key: "KeyV", altRight: true }, - "{": { key: "KeyB", altRight: true }, - "}": { key: "KeyN", altRight: true }, - "<": { key: "IntlBackslash", altRight: true }, - ">": { key: "KeyZ", altRight: true }, - "#": { key: "KeyX", altRight: true }, - "&": { key: "KeyC", altRight: true }, - ";": { key: "Comma", altRight: true }, - "*": { key: "Period", altRight: true }, - "÷": { key: "BracketRight", altRight: true }, - "×": { key: "Backslash", altRight: true }, - - // Punctuation - ",": { key: "Comma" }, - "?": { key: "Comma", shift: true }, - ".": { key: "Period" }, - ":": { key: "Period", shift: true }, - "-": { key: "Slash" }, - _: { key: "Slash", shift: true }, - " ": { key: "Space" }, - "\n": { key: "Enter" }, - Enter: { key: "Enter" }, - Tab: { key: "Tab" }, -} as Record; - -const keyDisplayMap = { - ...en_US.keyDisplayMap, - Digit0: "0", - Backquote: "ű", - Minus: "ö", - Equal: "ü", - BracketLeft: "ó", - BracketRight: "ő", - Semicolon: "á", - Quote: "é", - Backslash: "ú", - IntlBackslash: "í", - KeyY: "Z", - KeyZ: "Y", -} as Record; - -export const hu_HU: KeyboardLayout = { - isoCode, - name, - chars, - keyDisplayMap, - modifierDisplayMap: { - ...en_US.modifierDisplayMap, - altRight: "AltGr", - }, - virtualKeyboard: en_US.virtualKeyboard, -}; diff --git a/ui/src/keyboardLayouts/it_IT.ts b/ui/src/keyboardLayouts/it_IT.ts deleted file mode 100644 index 17826599f..000000000 --- a/ui/src/keyboardLayouts/it_IT.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"; - -import { en_US } from "./en_US"; // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard - -const name = "Italiano"; -const isoCode = "it-IT"; - -const chars = { - A: { key: "KeyA", shift: true }, - B: { key: "KeyB", shift: true }, - C: { key: "KeyC", shift: true }, - D: { key: "KeyD", shift: true }, - E: { key: "KeyE", shift: true }, - F: { key: "KeyF", shift: true }, - G: { key: "KeyG", shift: true }, - H: { key: "KeyH", shift: true }, - I: { key: "KeyI", shift: true }, - J: { key: "KeyJ", shift: true }, - K: { key: "KeyK", shift: true }, - L: { key: "KeyL", shift: true }, - M: { key: "KeyM", shift: true }, - N: { key: "KeyN", shift: true }, - O: { key: "KeyO", shift: true }, - P: { key: "KeyP", shift: true }, - Q: { key: "KeyQ", shift: true }, - R: { key: "KeyR", shift: true }, - S: { key: "KeyS", shift: true }, - T: { key: "KeyT", shift: true }, - U: { key: "KeyU", shift: true }, - V: { key: "KeyV", shift: true }, - W: { key: "KeyW", shift: true }, - X: { key: "KeyX", shift: true }, - Y: { key: "KeyY", shift: true }, - Z: { key: "KeyZ", shift: true }, - a: { key: "KeyA" }, - b: { key: "KeyB" }, - c: { key: "KeyC" }, - d: { key: "KeyD" }, - e: { key: "KeyE" }, - "€": { key: "KeyE", altRight: true }, - f: { key: "KeyF" }, - g: { key: "KeyG" }, - h: { key: "KeyH" }, - i: { key: "KeyI" }, - j: { key: "KeyJ" }, - k: { key: "KeyK" }, - l: { key: "KeyL" }, - m: { key: "KeyM" }, - n: { key: "KeyN" }, - o: { key: "KeyO" }, - p: { key: "KeyP" }, - q: { key: "KeyQ" }, - r: { key: "KeyR" }, - s: { key: "KeyS" }, - t: { key: "KeyT" }, - u: { key: "KeyU" }, - v: { key: "KeyV" }, - w: { key: "KeyW" }, - x: { key: "KeyX" }, - y: { key: "KeyY" }, - z: { key: "KeyZ" }, - "\\": { key: "Backquote" }, - "|": { key: "Backquote", shift: true }, - 1: { key: "Digit1" }, - "!": { key: "Digit1", shift: true }, - 2: { key: "Digit2" }, - '"': { key: "Digit2", shift: true }, - 3: { key: "Digit3" }, - "£": { key: "Digit3", shift: true }, - 4: { key: "Digit4" }, - $: { key: "Digit4", shift: true }, - 5: { key: "Digit5" }, - "%": { key: "Digit5", shift: true }, - 6: { key: "Digit6" }, - "&": { key: "Digit6", shift: true }, - 7: { key: "Digit7" }, - "/": { key: "Digit7", shift: true }, - 8: { key: "Digit8" }, - "(": { key: "Digit8", shift: true }, - 9: { key: "Digit9" }, - ")": { key: "Digit9", shift: true }, - 0: { key: "Digit0" }, - "=": { key: "Digit0", shift: true }, - "'": { key: "Minus" }, - "?": { key: "Minus", shift: true }, - ì: { key: "Equal" }, - "^": { key: "Equal", shift: true }, - è: { key: "BracketLeft" }, - é: { key: "BracketLeft", shift: true }, - "[": { key: "BracketLeft", altRight: true }, - "{": { key: "BracketLeft", shift: true, altRight: true }, - "+": { key: "BracketRight" }, - "*": { key: "BracketRight", shift: true }, - "]": { key: "BracketRight", altRight: true }, - "}": { key: "BracketRight", shift: true, altRight: true }, - ò: { key: "Semicolon" }, - ç: { key: "Semicolon", shift: true }, - "@": { key: "Semicolon", altRight: true }, - à: { key: "Quote" }, - "°": { key: "Quote", shift: true }, - "#": { key: "Quote", altRight: true }, - ù: { key: "Backslash" }, - "§": { key: "Backslash", shift: true }, - ",": { key: "Comma" }, - ";": { key: "Comma", shift: true }, - ".": { key: "Period" }, - ":": { key: "Period", shift: true }, - "-": { key: "Slash" }, - _: { key: "Slash", shift: true }, - "<": { key: "IntlBackslash" }, - ">": { key: "IntlBackslash", shift: true }, - " ": { key: "Space" }, - "\n": { key: "Enter" }, - Enter: { key: "Enter" }, - Tab: { key: "Tab" }, -} as Record; - -export const it_IT: KeyboardLayout = { - isoCode: isoCode, - name: name, - chars: chars, - // TODO need to localize these maps and layouts - keyDisplayMap: en_US.keyDisplayMap, - modifierDisplayMap: en_US.modifierDisplayMap, - virtualKeyboard: en_US.virtualKeyboard, -}; diff --git a/ui/src/keyboardLayouts/ja_JP.ts b/ui/src/keyboardLayouts/ja_JP.ts deleted file mode 100644 index f191e2271..000000000 --- a/ui/src/keyboardLayouts/ja_JP.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"; - -import { en_US } from "./en_US"; - -const name = "Japanese"; -const isoCode = "ja-JP"; - -// NOTE: -// This layout is primarily implemented with primarily targets Windows/Linux in mind on common JIS 106/109 keyboards. -// Across Windows, Linux, and macOS, there are small but important differences in: -// - how backslash ("\\") vs yen ("¥") are produced / interpreted, and -// - how Japanese IME mode switching keys behave (e.g. Henkan/Muhenkan/KatakanaHiragana). -// -// For Windows/Linux friendliness, we intentionally map both "\\" and "¥" to the Yen key, -// since many environments/applications render the Yen key as a backslash. -// -// TODO: -// If macOS-specific behavior is required, consider adding a dedicated macOS JIS layout -// (e.g. ja_JP_mac) and adjust mappings (often mapping "\\" to Backslash instead of Yen), -// plus any IME-key semantics differences as needed. - -export const chars = { - ...en_US.chars, - '"': { key: "Digit2", shift: true }, - "&": { key: "Digit6", shift: true }, - "'": { key: "Digit7", shift: true }, - "(": { key: "Digit8", shift: true }, - ")": { key: "Digit9", shift: true }, - "=": { key: "Minus", shift: true }, - "^": { key: "Equal" }, - "~": { key: "Equal", shift: true }, - "\\": { key: "Yen" }, - "¥": { key: "Yen" }, - "|": { key: "Yen", shift: true }, - "@": { key: "BracketLeft" }, - "`": { key: "BracketLeft", shift: true }, - "[": { key: "BracketRight" }, - "{": { key: "BracketRight", shift: true }, - ";": { key: "Semicolon" }, - "+": { key: "Semicolon", shift: true }, - ":": { key: "Quote" }, - "*": { key: "Quote", shift: true }, - "]": { key: "Backslash" }, - "}": { key: "Backslash", shift: true }, - _: { key: "KeyRO", shift: true }, -} as Record; - -// NOTE: -// We intentionally avoid providing Hiragana glyph labels on keycaps in the UI. -// Only about 5.1% of users typed with Kana input as of 2015; thus Kana legends are -// generally omitted to reduce visual clutter while keeping IME-related keys functional -// (Henkan/Muhenkan/KatakanaHiragana) for users who need them. -// Source: https://ja.wikipedia.org/wiki/%E3%81%8B%E3%81%AA%E5%85%A5%E5%8A%9B#%E3%81%8B%E3%81%AA%E5%85%A5%E5%8A%9B%E3%81%AE%E5%88%A9%E7%94%A8%E7%8A%B6%E6%B3%81 -export const keyDisplayMap: Record = { - ...en_US.keyDisplayMap, - "(Digit2)": '"', - "(Digit6)": "&", - "(Digit7)": "'", - "(Digit8)": "(", - "(Digit9)": ")", - "(Minus)": "=", - Equal: "^", - "(Equal)": "~", - Yen: "¥", - "(Yen)": "|", - KeyRO: "\\", - "(KeyRO)": "_", - Henkan: "変換", - Muhenkan: "無変換", - KatakanaHiragana: "ひらがな", - Backquote: "半角/全角", - "(KatakanaHiragana)": "ローマ字", - BracketLeft: "@", - "(BracketLeft)": "`", - BracketRight: "[", - "(BracketRight)": "{", - Semicolon: ";", - "(Semicolon)": "+", - Quote: ":", - "(Quote)": "*", - Backslash: "]", - "(Backslash)": "}", - ContextMenu: "Menu", - - // UI-only notes: - // - Keep a placeholder label for shifted Digit0 to avoid a "missing" keycap in the UI. - // - Use "⏎" to hint at the tall, JIS/ISO-style L-shaped Enter key in the UI, - // while internally representing it with two virtual buttons. - "(Digit0)": " ", - "(Enter)": "⏎", -}; - -export const virtualKeyboard = { - ...en_US.virtualKeyboard, - main: { - default: [ - "CtrlAltDelete AltMetaEscape CtrlAltBackspace", - "Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12", - "Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Yen Backspace", - "Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Enter", - "CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Backslash (Enter)", - "ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash KeyRO ShiftRight", - "ControlLeft MetaLeft AltLeft Muhenkan Space Henkan KatakanaHiragana AltRight MetaRight ContextMenu ControlRight", - ], - shift: [ - "CtrlAltDelete AltMetaEscape CtrlAltBackspace", - "Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12", - "Backquote (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Yen) (Backspace)", - "Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) Enter", - "CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) (Backslash) (Enter)", - "ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) (KeyRO) ShiftRight", - "ControlLeft MetaLeft AltLeft Muhenkan Space Henkan (KatakanaHiragana) AltRight MetaRight ContextMenu ControlRight", - ], - }, -}; - -export const ja_JP: KeyboardLayout = { - isoCode, - name, - chars, - keyDisplayMap, - modifierDisplayMap: en_US.modifierDisplayMap, - virtualKeyboard, -}; diff --git a/ui/src/keyboardLayouts/nb_NO.ts b/ui/src/keyboardLayouts/nb_NO.ts deleted file mode 100644 index 943a8c087..000000000 --- a/ui/src/keyboardLayouts/nb_NO.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"; - -import { en_US } from "./en_US"; // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard - -const name = "Norsk bokmål"; -const isoCode = "nb-NO"; - -const keyTrema: KeyCombo = { key: "BracketRight" }; // tréma (umlaut), two dots placed above a vowel -const keyAcute: KeyCombo = { key: "Equal", altRight: true }; // accent aigu (acute accent), mark ´ placed above the letter -const keyHat: KeyCombo = { key: "BracketRight", shift: true }; // accent circonflexe (accent hat), mark ^ placed above the letter -const keyGrave: KeyCombo = { key: "Equal", shift: true }; // accent grave, mark ` placed above the letter -const keyTilde: KeyCombo = { key: "BracketRight", altRight: true }; // tilde, mark ~ placed above the letter - -const chars = { - A: { key: "KeyA", shift: true }, - Ä: { key: "KeyA", shift: true, accentKey: keyTrema }, - Á: { key: "KeyA", shift: true, accentKey: keyAcute }, - Â: { key: "KeyA", shift: true, accentKey: keyHat }, - À: { key: "KeyA", shift: true, accentKey: keyGrave }, - Ã: { key: "KeyA", shift: true, accentKey: keyTilde }, - B: { key: "KeyB", shift: true }, - C: { key: "KeyC", shift: true }, - D: { key: "KeyD", shift: true }, - E: { key: "KeyE", shift: true }, - Ë: { key: "KeyE", shift: true, accentKey: keyTrema }, - É: { key: "KeyE", shift: true, accentKey: keyAcute }, - Ê: { key: "KeyE", shift: true, accentKey: keyHat }, - È: { key: "KeyE", shift: true, accentKey: keyGrave }, - Ẽ: { key: "KeyE", shift: true, accentKey: keyTilde }, - F: { key: "KeyF", shift: true }, - G: { key: "KeyG", shift: true }, - H: { key: "KeyH", shift: true }, - I: { key: "KeyI", shift: true }, - Ï: { key: "KeyI", shift: true, accentKey: keyTrema }, - Í: { key: "KeyI", shift: true, accentKey: keyAcute }, - Î: { key: "KeyI", shift: true, accentKey: keyHat }, - Ì: { key: "KeyI", shift: true, accentKey: keyGrave }, - Ĩ: { key: "KeyI", shift: true, accentKey: keyTilde }, - J: { key: "KeyJ", shift: true }, - K: { key: "KeyK", shift: true }, - L: { key: "KeyL", shift: true }, - M: { key: "KeyM", shift: true }, - N: { key: "KeyN", shift: true }, - O: { key: "KeyO", shift: true }, - Ö: { key: "KeyO", shift: true, accentKey: keyTrema }, - Ó: { key: "KeyO", shift: true, accentKey: keyAcute }, - Ô: { key: "KeyO", shift: true, accentKey: keyHat }, - Ò: { key: "KeyO", shift: true, accentKey: keyGrave }, - Õ: { key: "KeyO", shift: true, accentKey: keyTilde }, - P: { key: "KeyP", shift: true }, - Q: { key: "KeyQ", shift: true }, - R: { key: "KeyR", shift: true }, - S: { key: "KeyS", shift: true }, - T: { key: "KeyT", shift: true }, - U: { key: "KeyU", shift: true }, - Ü: { key: "KeyU", shift: true, accentKey: keyTrema }, - Ú: { key: "KeyU", shift: true, accentKey: keyAcute }, - Û: { key: "KeyU", shift: true, accentKey: keyHat }, - Ù: { key: "KeyU", shift: true, accentKey: keyGrave }, - Ũ: { key: "KeyU", shift: true, accentKey: keyTilde }, - V: { key: "KeyV", shift: true }, - W: { key: "KeyW", shift: true }, - X: { key: "KeyX", shift: true }, - Y: { key: "KeyY", shift: true }, - Z: { key: "KeyZ", shift: true }, - a: { key: "KeyA" }, - ä: { key: "KeyA", accentKey: keyTrema }, - á: { key: "KeyA", accentKey: keyAcute }, - â: { key: "KeyA", accentKey: keyHat }, - à: { key: "KeyA", accentKey: keyGrave }, - ã: { key: "KeyA", accentKey: keyTilde }, - b: { key: "KeyB" }, - c: { key: "KeyC" }, - d: { key: "KeyD" }, - e: { key: "KeyE" }, - ë: { key: "KeyE", accentKey: keyTrema }, - é: { key: "KeyE", accentKey: keyAcute }, - ê: { key: "KeyE", accentKey: keyHat }, - è: { key: "KeyE", accentKey: keyGrave }, - ẽ: { key: "KeyE", accentKey: keyTilde }, - "€": { key: "KeyE", altRight: true }, - f: { key: "KeyF" }, - g: { key: "KeyG" }, - h: { key: "KeyH" }, - i: { key: "KeyI" }, - ï: { key: "KeyI", accentKey: keyTrema }, - í: { key: "KeyI", accentKey: keyAcute }, - î: { key: "KeyI", accentKey: keyHat }, - ì: { key: "KeyI", accentKey: keyGrave }, - ĩ: { key: "KeyI", accentKey: keyTilde }, - j: { key: "KeyJ" }, - k: { key: "KeyK" }, - l: { key: "KeyL" }, - m: { key: "KeyM" }, - n: { key: "KeyN" }, - o: { key: "KeyO" }, - ö: { key: "KeyO", accentKey: keyTrema }, - ó: { key: "KeyO", accentKey: keyAcute }, - ô: { key: "KeyO", accentKey: keyHat }, - ò: { key: "KeyO", accentKey: keyGrave }, - õ: { key: "KeyO", accentKey: keyTilde }, - p: { key: "KeyP" }, - q: { key: "KeyQ" }, - r: { key: "KeyR" }, - s: { key: "KeyS" }, - t: { key: "KeyT" }, - u: { key: "KeyU" }, - ü: { key: "KeyU", accentKey: keyTrema }, - ú: { key: "KeyU", accentKey: keyAcute }, - û: { key: "KeyU", accentKey: keyHat }, - ù: { key: "KeyU", accentKey: keyGrave }, - ũ: { key: "KeyU", accentKey: keyTilde }, - v: { key: "KeyV" }, - w: { key: "KeyW" }, - x: { key: "KeyX" }, - y: { key: "KeyY" }, - z: { key: "KeyZ" }, - "|": { key: "Backquote" }, - "§": { key: "Backquote", shift: true }, - 1: { key: "Digit1" }, - "!": { key: "Digit1", shift: true }, - 2: { key: "Digit2" }, - '"': { key: "Digit2", shift: true }, - "@": { key: "Digit2", altRight: true }, - 3: { key: "Digit3" }, - "#": { key: "Digit3", shift: true }, - "£": { key: "Digit3", altRight: true }, - 4: { key: "Digit4" }, - "¤": { key: "Digit4", shift: true }, - $: { key: "Digit4", altRight: true }, - 5: { key: "Digit5" }, - "%": { key: "Digit5", shift: true }, - 6: { key: "Digit6" }, - "&": { key: "Digit6", shift: true }, - 7: { key: "Digit7" }, - "/": { key: "Digit7", shift: true }, - "{": { key: "Digit7", altRight: true }, - 8: { key: "Digit8" }, - "(": { key: "Digit8", shift: true }, - "[": { key: "Digit8", altRight: true }, - 9: { key: "Digit9" }, - ")": { key: "Digit9", shift: true }, - "]": { key: "Digit9", altRight: true }, - 0: { key: "Digit0" }, - "=": { key: "Digit0", shift: true }, - "}": { key: "Digit0", altRight: true }, - "+": { key: "Minus" }, - "?": { key: "Minus", shift: true }, - "\\": { key: "Equal" }, - å: { key: "BracketLeft" }, - Å: { key: "BracketLeft", shift: true }, - ø: { key: "Semicolon" }, - Ø: { key: "Semicolon", shift: true }, - æ: { key: "Quote" }, - Æ: { key: "Quote", shift: true }, - "'": { key: "Backslash" }, - "*": { key: "Backslash", shift: true }, - ",": { key: "Comma" }, - ";": { key: "Comma", shift: true }, - ".": { key: "Period" }, - ":": { key: "Period", shift: true }, - "-": { key: "Slash" }, - _: { key: "Slash", shift: true }, - "<": { key: "IntlBackslash" }, - ">": { key: "IntlBackslash", shift: true }, - " ": { key: "Space" }, - "\n": { key: "Enter" }, - Enter: { key: "Enter" }, - Tab: { key: "Tab" }, -} as Record; - -export const nb_NO: KeyboardLayout = { - isoCode: isoCode, - name: name, - chars: chars, - // TODO need to localize these maps and layouts - keyDisplayMap: en_US.keyDisplayMap, - modifierDisplayMap: en_US.modifierDisplayMap, - virtualKeyboard: en_US.virtualKeyboard, -}; diff --git a/ui/src/keyboardLayouts/pl_PL.ts b/ui/src/keyboardLayouts/pl_PL.ts deleted file mode 100644 index e370bfe9a..000000000 --- a/ui/src/keyboardLayouts/pl_PL.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"; - -import { en_US, chars as en_US_chars } from "./en_US"; - -const name = "Polski"; -const isoCode = "pl-PL"; - -// Polish Programmer layout (kbdpl1): QWERTY + AltGr diacritics, no dead keys -const chars: Record = { - ...en_US_chars, - // lowercase diacritics (AltGr + letter) - ą: { key: "KeyA", altRight: true }, - ć: { key: "KeyC", altRight: true }, - ę: { key: "KeyE", altRight: true }, - ł: { key: "KeyL", altRight: true }, - ń: { key: "KeyN", altRight: true }, - ó: { key: "KeyO", altRight: true }, - ś: { key: "KeyS", altRight: true }, - ż: { key: "KeyZ", altRight: true }, - ź: { key: "KeyX", altRight: true }, - // uppercase diacritics (Shift + AltGr + letter) - Ą: { key: "KeyA", shift: true, altRight: true }, - Ć: { key: "KeyC", shift: true, altRight: true }, - Ę: { key: "KeyE", shift: true, altRight: true }, - Ł: { key: "KeyL", shift: true, altRight: true }, - Ń: { key: "KeyN", shift: true, altRight: true }, - Ó: { key: "KeyO", shift: true, altRight: true }, - Ś: { key: "KeyS", shift: true, altRight: true }, - Ż: { key: "KeyZ", shift: true, altRight: true }, - Ź: { key: "KeyX", shift: true, altRight: true }, -}; - -export const pl_PL: KeyboardLayout = { - isoCode: isoCode, - name: name, - chars: chars, - keyDisplayMap: en_US.keyDisplayMap, - modifierDisplayMap: en_US.modifierDisplayMap, - virtualKeyboard: en_US.virtualKeyboard, -}; diff --git a/ui/src/keyboardLayouts/pt_PT.ts b/ui/src/keyboardLayouts/pt_PT.ts deleted file mode 100644 index 731b3ad7b..000000000 --- a/ui/src/keyboardLayouts/pt_PT.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"; - -import { en_US } from "./en_US"; // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard - -const name = "Português"; -const isoCode = "pt-PT"; - -// Dead keys -const keyAcute: KeyCombo = { key: "BracketRight" }; // ´ (dead) on SC 1B base -const keyGrave: KeyCombo = { key: "BracketRight", shift: true }; // ` (dead) on SC 1B shift -const keyTrema: KeyCombo = { key: "BracketLeft", altRight: true }; // ¨ (dead) on SC 1A AltGr -const keyTilde: KeyCombo = { key: "Backslash" }; // ~ (dead) on SC 2B base -const keyHat: KeyCombo = { key: "Backslash", shift: true }; // ^ (dead) on SC 2B shift - -const chars = { - // Uppercase letters - A: { key: "KeyA", shift: true }, - Á: { key: "KeyA", shift: true, accentKey: keyAcute }, - À: { key: "KeyA", shift: true, accentKey: keyGrave }, - Ä: { key: "KeyA", shift: true, accentKey: keyTrema }, - Ã: { key: "KeyA", shift: true, accentKey: keyTilde }, - Â: { key: "KeyA", shift: true, accentKey: keyHat }, - B: { key: "KeyB", shift: true }, - C: { key: "KeyC", shift: true }, - D: { key: "KeyD", shift: true }, - E: { key: "KeyE", shift: true }, - É: { key: "KeyE", shift: true, accentKey: keyAcute }, - È: { key: "KeyE", shift: true, accentKey: keyGrave }, - Ë: { key: "KeyE", shift: true, accentKey: keyTrema }, - Ê: { key: "KeyE", shift: true, accentKey: keyHat }, - F: { key: "KeyF", shift: true }, - G: { key: "KeyG", shift: true }, - H: { key: "KeyH", shift: true }, - I: { key: "KeyI", shift: true }, - Í: { key: "KeyI", shift: true, accentKey: keyAcute }, - Ì: { key: "KeyI", shift: true, accentKey: keyGrave }, - Ï: { key: "KeyI", shift: true, accentKey: keyTrema }, - Î: { key: "KeyI", shift: true, accentKey: keyHat }, - J: { key: "KeyJ", shift: true }, - K: { key: "KeyK", shift: true }, - L: { key: "KeyL", shift: true }, - M: { key: "KeyM", shift: true }, - N: { key: "KeyN", shift: true }, - Ñ: { key: "KeyN", shift: true, accentKey: keyTilde }, - O: { key: "KeyO", shift: true }, - Ó: { key: "KeyO", shift: true, accentKey: keyAcute }, - Ò: { key: "KeyO", shift: true, accentKey: keyGrave }, - Ö: { key: "KeyO", shift: true, accentKey: keyTrema }, - Õ: { key: "KeyO", shift: true, accentKey: keyTilde }, - Ô: { key: "KeyO", shift: true, accentKey: keyHat }, - P: { key: "KeyP", shift: true }, - Q: { key: "KeyQ", shift: true }, - R: { key: "KeyR", shift: true }, - S: { key: "KeyS", shift: true }, - T: { key: "KeyT", shift: true }, - U: { key: "KeyU", shift: true }, - Ú: { key: "KeyU", shift: true, accentKey: keyAcute }, - Ù: { key: "KeyU", shift: true, accentKey: keyGrave }, - Ü: { key: "KeyU", shift: true, accentKey: keyTrema }, - Û: { key: "KeyU", shift: true, accentKey: keyHat }, - V: { key: "KeyV", shift: true }, - W: { key: "KeyW", shift: true }, - X: { key: "KeyX", shift: true }, - Y: { key: "KeyY", shift: true }, - Ý: { key: "KeyY", shift: true, accentKey: keyAcute }, - Z: { key: "KeyZ", shift: true }, - - // Lowercase letters - a: { key: "KeyA" }, - á: { key: "KeyA", accentKey: keyAcute }, - à: { key: "KeyA", accentKey: keyGrave }, - ä: { key: "KeyA", accentKey: keyTrema }, - ã: { key: "KeyA", accentKey: keyTilde }, - â: { key: "KeyA", accentKey: keyHat }, - b: { key: "KeyB" }, - c: { key: "KeyC" }, - d: { key: "KeyD" }, - e: { key: "KeyE" }, - é: { key: "KeyE", accentKey: keyAcute }, - è: { key: "KeyE", accentKey: keyGrave }, - ë: { key: "KeyE", accentKey: keyTrema }, - ê: { key: "KeyE", accentKey: keyHat }, - "€": { key: "KeyE", altRight: true }, - f: { key: "KeyF" }, - g: { key: "KeyG" }, - h: { key: "KeyH" }, - i: { key: "KeyI" }, - í: { key: "KeyI", accentKey: keyAcute }, - ì: { key: "KeyI", accentKey: keyGrave }, - ï: { key: "KeyI", accentKey: keyTrema }, - î: { key: "KeyI", accentKey: keyHat }, - j: { key: "KeyJ" }, - k: { key: "KeyK" }, - l: { key: "KeyL" }, - m: { key: "KeyM" }, - n: { key: "KeyN" }, - ñ: { key: "KeyN", accentKey: keyTilde }, - o: { key: "KeyO" }, - ó: { key: "KeyO", accentKey: keyAcute }, - ò: { key: "KeyO", accentKey: keyGrave }, - ö: { key: "KeyO", accentKey: keyTrema }, - õ: { key: "KeyO", accentKey: keyTilde }, - ô: { key: "KeyO", accentKey: keyHat }, - p: { key: "KeyP" }, - q: { key: "KeyQ" }, - r: { key: "KeyR" }, - s: { key: "KeyS" }, - t: { key: "KeyT" }, - u: { key: "KeyU" }, - ú: { key: "KeyU", accentKey: keyAcute }, - ù: { key: "KeyU", accentKey: keyGrave }, - ü: { key: "KeyU", accentKey: keyTrema }, - û: { key: "KeyU", accentKey: keyHat }, - v: { key: "KeyV" }, - w: { key: "KeyW" }, - x: { key: "KeyX" }, - y: { key: "KeyY" }, - ý: { key: "KeyY", accentKey: keyAcute }, - ÿ: { key: "KeyY", accentKey: keyTrema }, - z: { key: "KeyZ" }, - - // SC 29 (OEM_5) → Backquote: \ | - "\\": { key: "Backquote" }, - "|": { key: "Backquote", shift: true }, - - // Number row - 1: { key: "Digit1" }, - "!": { key: "Digit1", shift: true }, - 2: { key: "Digit2" }, - '"': { key: "Digit2", shift: true }, - "@": { key: "Digit2", altRight: true }, - 3: { key: "Digit3" }, - "#": { key: "Digit3", shift: true }, - "£": { key: "Digit3", altRight: true }, - 4: { key: "Digit4" }, - $: { key: "Digit4", shift: true }, - "§": { key: "Digit4", altRight: true }, - 5: { key: "Digit5" }, - "%": { key: "Digit5", shift: true }, - 6: { key: "Digit6" }, - "&": { key: "Digit6", shift: true }, - 7: { key: "Digit7" }, - "/": { key: "Digit7", shift: true }, - "{": { key: "Digit7", altRight: true }, - 8: { key: "Digit8" }, - "(": { key: "Digit8", shift: true }, - "[": { key: "Digit8", altRight: true }, - 9: { key: "Digit9" }, - ")": { key: "Digit9", shift: true }, - "]": { key: "Digit9", altRight: true }, - 0: { key: "Digit0" }, - "=": { key: "Digit0", shift: true }, - "}": { key: "Digit0", altRight: true }, - - // SC 0C (OEM_4) → Minus: ' ? - "'": { key: "Minus" }, - "?": { key: "Minus", shift: true }, - - // SC 0D (OEM_6) → Equal: « » - "«": { key: "Equal" }, - "»": { key: "Equal", shift: true }, - - // SC 1A (OEM_PLUS) → BracketLeft: + * ¨(dead) - "+": { key: "BracketLeft" }, - "*": { key: "BracketLeft", shift: true }, - "¨": { key: "BracketLeft", altRight: true, deadKey: true }, - - // SC 1B (OEM_1) → BracketRight: ´(dead) `(dead) - "´": { key: "BracketRight", deadKey: true }, - "`": { key: "BracketRight", shift: true, deadKey: true }, - - // SC 27 (OEM_3) → Semicolon: ç Ç - ç: { key: "Semicolon" }, - Ç: { key: "Semicolon", shift: true }, - - // SC 28 (OEM_7) → Quote: º ª - º: { key: "Quote" }, - ª: { key: "Quote", shift: true }, - - // SC 2B (OEM_2) → Backslash: ~(dead) ^(dead) - "~": { key: "Backslash", deadKey: true }, - "^": { key: "Backslash", shift: true, deadKey: true }, - - // SC 33-35: Comma, Period, Slash - ",": { key: "Comma" }, - ";": { key: "Comma", shift: true }, - ".": { key: "Period" }, - ":": { key: "Period", shift: true }, - "-": { key: "Slash" }, - _: { key: "Slash", shift: true }, - - // SC 56 (OEM_102) → IntlBackslash: < > - "<": { key: "IntlBackslash" }, - ">": { key: "IntlBackslash", shift: true }, - - " ": { key: "Space" }, - "\n": { key: "Enter" }, - Enter: { key: "Enter" }, - Tab: { key: "Tab" }, -} as Record; - -export const pt_PT: KeyboardLayout = { - isoCode: isoCode, - name: name, - chars: chars, - keyDisplayMap: en_US.keyDisplayMap, - modifierDisplayMap: en_US.modifierDisplayMap, - virtualKeyboard: en_US.virtualKeyboard, -}; diff --git a/ui/src/keyboardLayouts/ru_RU.ts b/ui/src/keyboardLayouts/ru_RU.ts deleted file mode 100644 index 8523a28c7..000000000 --- a/ui/src/keyboardLayouts/ru_RU.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"; -import { en_US } from "./en_US"; - -const name = "Русская"; -const isoCode = "ru-RU"; - -export const chars = { - ...en_US.chars, - А: { key: "KeyF", shift: true }, - Б: { key: "Comma", shift: true }, - В: { key: "KeyD", shift: true }, - Г: { key: "KeyU", shift: true }, - Д: { key: "KeyL", shift: true }, - Е: { key: "KeyT", shift: true }, - Ё: { key: "Backquote", shift: true }, - Ж: { key: "Semicolon", shift: true }, - З: { key: "KeyP", shift: true }, - И: { key: "KeyB", shift: true }, - Й: { key: "KeyQ", shift: true }, - К: { key: "KeyR", shift: true }, - Л: { key: "KeyK", shift: true }, - М: { key: "KeyV", shift: true }, - Н: { key: "KeyY", shift: true }, - О: { key: "KeyJ", shift: true }, - П: { key: "KeyG", shift: true }, - Р: { key: "KeyH", shift: true }, - С: { key: "KeyC", shift: true }, - Т: { key: "KeyN", shift: true }, - У: { key: "KeyE", shift: true }, - Ф: { key: "KeyA", shift: true }, - Х: { key: "BracketLeft", shift: true }, - Ц: { key: "KeyW", shift: true }, - Ч: { key: "KeyX", shift: true }, - Ш: { key: "KeyI", shift: true }, - Щ: { key: "KeyO", shift: true }, - Ъ: { key: "BracketRight", shift: true }, - Ы: { key: "KeyS", shift: true }, - Ь: { key: "KeyM", shift: true }, - Э: { key: "Quote", shift: true }, - Ю: { key: "Period", shift: true }, - Я: { key: "KeyZ", shift: true }, - а: { key: "KeyF" }, - б: { key: "Comma" }, - в: { key: "KeyD" }, - г: { key: "KeyU" }, - д: { key: "KeyL" }, - е: { key: "KeyT" }, - ё: { key: "Backquote" }, - ж: { key: "Semicolon" }, - з: { key: "KeyP" }, - и: { key: "KeyB" }, - й: { key: "KeyQ" }, - к: { key: "KeyR" }, - л: { key: "KeyK" }, - м: { key: "KeyV" }, - н: { key: "KeyY" }, - о: { key: "KeyJ" }, - п: { key: "KeyG" }, - р: { key: "KeyH" }, - с: { key: "KeyC" }, - т: { key: "KeyN" }, - у: { key: "KeyE" }, - ф: { key: "KeyA" }, - х: { key: "BracketLeft" }, - ц: { key: "KeyW" }, - ч: { key: "KeyX" }, - ш: { key: "KeyI" }, - щ: { key: "KeyO" }, - ъ: { key: "BracketRight" }, - ы: { key: "KeyS" }, - ь: { key: "KeyM" }, - э: { key: "Quote" }, - ю: { key: "Period" }, - я: { key: "KeyZ" }, - '"': { key: "Digit2", shift: true }, - "№": { key: "Digit3", shift: true }, - ";": { key: "Digit4", shift: true }, - ":": { key: "Digit6", shift: true }, - "?": { key: "Digit7", shift: true }, - ".": { key: "Slash" }, - ",": { key: "Slash", shift: true }, -} as Record; - -export const keyDisplayMap = { - ...en_US.keyDisplayMap, - KeyF: "а", - Comma: "б", - KeyD: "в", - KeyU: "г", - KeyL: "д", - KeyT: "е", - Backquote: "ё", - Semicolon: "ж", - KeyP: "з", - KeyB: "и", - KeyQ: "й", - KeyR: "к", - KeyK: "л", - KeyV: "м", - KeyY: "н", - KeyJ: "о", - KeyG: "п", - KeyH: "р", - KeyC: "с", - KeyN: "т", - KeyE: "у", - KeyA: "ф", - BracketLeft: "х", - KeyW: "ц", - KeyX: "ч", - KeyI: "ш", - KeyO: "щ", - BracketRight: "ъ", - KeyS: "ы", - KeyM: "ь", - Quote: "э", - Period: "ю", - KeyZ: "я", - Slash: ".", - "(KeyF)": "А", - "(Comma)": "Б", - "(KeyD)": "В", - "(KeyU)": "Г", - "(KeyL)": "Д", - "(KeyT)": "Е", - "(Backquote)": "Ё", - "(Semicolon)": "Ж", - "(KeyP)": "З", - "(KeyB)": "И", - "(KeyQ)": "Й", - "(KeyR)": "К", - "(KeyK)": "Л", - "(KeyV)": "М", - "(KeyY)": "Н", - "(KeyJ)": "О", - "(KeyG)": "П", - "(KeyH)": "Р", - "(KeyC)": "С", - "(KeyN)": "Т", - "(KeyE)": "У", - "(KeyA)": "Ф", - "(BracketLeft)": "Х", - "(KeyW)": "Ц", - "(KeyX)": "Ч", - "(KeyI)": "Ш", - "(KeyO)": "Щ", - "(BracketRight)": "Ъ", - "(KeyS)": "Ы", - "(KeyM)": "Ь", - "(Quote)": "Э", - "(Period)": "Ю", - "(KeyZ)": "Я", - "(Digit2)": '"', - "(Digit3)": "№", - "(Digit4)": ";", - "(Digit6)": ":", - "(Digit7)": "?", - "(Slash)": ",", -}; - -export const modifierDisplayMap = en_US.modifierDisplayMap; -export const virtualKeyboard = en_US.virtualKeyboard; - -export const ru_RU: KeyboardLayout = { - isoCode, - name, - chars, - keyDisplayMap, - modifierDisplayMap, - virtualKeyboard, -}; diff --git a/ui/src/keyboardLayouts/sl_SI.ts b/ui/src/keyboardLayouts/sl_SI.ts deleted file mode 100644 index 80f6273d0..000000000 --- a/ui/src/keyboardLayouts/sl_SI.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"; - -import { en_US } from "./en_US"; // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard - -const name = "Slovenian"; -const isoCode = "sl-SI"; - -export const chars = { - A: { key: "KeyA", shift: true }, - B: { key: "KeyB", shift: true }, - C: { key: "KeyC", shift: true }, - Č: { key: "Semicolon", shift: true }, - Ć: { key: "Quote", shift: true }, - D: { key: "KeyD", shift: true }, - Đ: { key: "BracketRight", shift: true }, - E: { key: "KeyE", shift: true }, - F: { key: "KeyF", shift: true }, - G: { key: "KeyG", shift: true }, - H: { key: "KeyH", shift: true }, - I: { key: "KeyI", shift: true }, - J: { key: "KeyJ", shift: true }, - K: { key: "KeyK", shift: true }, - L: { key: "KeyL", shift: true }, - M: { key: "KeyM", shift: true }, - N: { key: "KeyN", shift: true }, - O: { key: "KeyO", shift: true }, - P: { key: "KeyP", shift: true }, - Q: { key: "KeyQ", shift: true }, - R: { key: "KeyR", shift: true }, - S: { key: "KeyS", shift: true }, - Š: { key: "BracketLeft", shift: true }, - T: { key: "KeyT", shift: true }, - U: { key: "KeyU", shift: true }, - V: { key: "KeyV", shift: true }, - W: { key: "KeyW", shift: true }, - X: { key: "KeyX", shift: true }, - Y: { key: "KeyZ", shift: true }, - Z: { key: "KeyY", shift: true }, - Ž: { key: "Backslash", shift: true }, - a: { key: "KeyA" }, - b: { key: "KeyB" }, - c: { key: "KeyC" }, - č: { key: "Semicolon" }, - ć: { key: "Quote" }, - d: { key: "KeyD" }, - đ: { key: "BracketRight" }, - e: { key: "KeyE" }, - f: { key: "KeyF" }, - g: { key: "KeyG" }, - h: { key: "KeyH" }, - i: { key: "KeyI" }, - j: { key: "KeyJ" }, - k: { key: "KeyK" }, - l: { key: "KeyL" }, - m: { key: "KeyM" }, - n: { key: "KeyN" }, - o: { key: "KeyO" }, - p: { key: "KeyP" }, - q: { key: "KeyQ" }, - r: { key: "KeyR" }, - s: { key: "KeyS" }, - š: { key: "BracketLeft" }, - t: { key: "KeyT" }, - u: { key: "KeyU" }, - v: { key: "KeyV" }, - w: { key: "KeyW" }, - x: { key: "KeyX" }, - y: { key: "KeyZ" }, - z: { key: "KeyY" }, - ž: { key: "Backslash" }, - 1: { key: "Digit1" }, - "!": { key: "Digit1", shift: true }, - 2: { key: "Digit2" }, - '"': { key: "Digit2", shift: true }, - 3: { key: "Digit3" }, - "#": { key: "Digit3", shift: true }, - 4: { key: "Digit4" }, - $: { key: "Digit4", shift: true }, - 5: { key: "Digit5" }, - "%": { key: "Digit5", shift: true }, - 6: { key: "Digit6" }, - "&": { key: "Digit6", shift: true }, - 7: { key: "Digit7" }, - "/": { key: "Digit7", shift: true }, - 8: { key: "Digit8" }, - "(": { key: "Digit8", shift: true }, - 9: { key: "Digit9" }, - ")": { key: "Digit9", shift: true }, - 0: { key: "Digit0" }, - "=": { key: "Digit0", shift: true }, - "'": { key: "Minus" }, - "?": { key: "Minus", shift: true }, - "+": { key: "Equal" }, - "*": { key: "Equal", shift: true }, - - "<": { key: "IntlBackslash" }, - ">": { key: "IntlBackslash", shift: true }, - ",": { key: "Comma" }, - ";": { key: "Comma", shift: true }, - ".": { key: "Period" }, - ":": { key: "Period", shift: true }, - "-": { key: "Slash" }, - _: { key: "Slash", shift: true }, - - "~": { key: "Digit1", shift: true }, - ˇ: { key: "Digit2", shift: true }, - "^": { key: "Digit3", shift: true }, - "˘": { key: "Digit4", shift: true }, - "°": { key: "Digit5", shift: true }, - "˛": { key: "Digit6", shift: true }, - "`": { key: "Digit7", shift: true }, - "˙": { key: "Digit8", shift: true }, - "´": { key: "Digit9", shift: true }, - "˝": { key: "Digit0", shift: true }, - "¨": { key: "Minus", shift: true }, - "¸": { key: "Equal", shift: true }, - "\\": { key: "KeyQ", AltGr: true }, - "|": { key: "KeyW", AltGr: true }, - "€": { key: "KeyE", AltGr: true }, - "÷": { key: "BracketLeft", AltGr: true }, - "×": { key: "BracketRight", AltGr: true }, - "[": { key: "KeyF", AltGr: true }, - "]": { key: "KeyG", AltGr: true }, - ł: { key: "KeyK", AltGr: true }, - Ł: { key: "KeyL", AltGr: true }, - ß: { key: "Quote", AltGr: true }, - "¤": { key: "Backslash", AltGr: true }, - "@": { key: "KeyV", AltGr: true }, - "{": { key: "KeyB", AltGr: true }, - "}": { key: "KeyN", AltGr: true }, - "§": { key: "KeyM", AltGr: true }, - // "<": { key: "Comma", AltGr: true }, // Can be typed in two different locations (`IntlBackslash`) - // ">": { key: "Period", AltGr: true }, // Can be typed in two different locations (`IntlBackslash+Shift`) - - " ": { key: "Space" }, - "\n": { key: "Enter" }, - Enter: { key: "Enter" }, - Escape: { key: "Escape" }, - Tab: { key: "Tab" }, - PrintScreen: { key: "Prt Sc" }, - SystemRequest: { key: "Prt Sc", shift: true }, - ScrollLock: { key: "ScrollLock" }, - Pause: { key: "Pause" }, - Break: { key: "Pause", shift: true }, - Insert: { key: "Insert" }, - Delete: { key: "Delete" }, -} as Record; - -export const sl_SI: KeyboardLayout = { - isoCode: isoCode, - name: name, - chars: chars, - // TODO need to localize these maps and layouts - keyDisplayMap: en_US.keyDisplayMap, - modifierDisplayMap: en_US.modifierDisplayMap, - virtualKeyboard: en_US.virtualKeyboard, -}; diff --git a/ui/src/keyboardLayouts/sv_SE.ts b/ui/src/keyboardLayouts/sv_SE.ts deleted file mode 100644 index a75a440f7..000000000 --- a/ui/src/keyboardLayouts/sv_SE.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { KeyboardLayout, KeyCombo } from "../keyboardLayouts"; - -import { en_US } from "./en_US"; // for fallback of keyDisplayMap, modifierDisplayMap, and virtualKeyboard - -const name = "Svenska"; -const isoCode = "sv-SE"; - -const keyTrema: KeyCombo = { key: "BracketRight" }; // tréma (umlaut), two dots placed above a vowel -const keyAcute: KeyCombo = { key: "Equal" }; // accent aigu (acute accent), mark ´ placed above the letter -const keyHat: KeyCombo = { key: "BracketRight", shift: true }; // accent circonflexe (accent hat), mark ^ placed above the letter -const keyGrave: KeyCombo = { key: "Equal", shift: true }; // accent grave, mark ` placed above the letter -const keyTilde: KeyCombo = { key: "BracketRight", altRight: true }; // tilde, mark ~ placed above the letter - -const chars = { - A: { key: "KeyA", shift: true }, - Á: { key: "KeyA", shift: true, accentKey: keyAcute }, - Â: { key: "KeyA", shift: true, accentKey: keyHat }, - À: { key: "KeyA", shift: true, accentKey: keyGrave }, - Ã: { key: "KeyA", shift: true, accentKey: keyTilde }, - B: { key: "KeyB", shift: true }, - C: { key: "KeyC", shift: true }, - D: { key: "KeyD", shift: true }, - E: { key: "KeyE", shift: true }, - Ë: { key: "KeyE", shift: true, accentKey: keyTrema }, - É: { key: "KeyE", shift: true, accentKey: keyAcute }, - Ê: { key: "KeyE", shift: true, accentKey: keyHat }, - È: { key: "KeyE", shift: true, accentKey: keyGrave }, - Ẽ: { key: "KeyE", shift: true, accentKey: keyTilde }, - F: { key: "KeyF", shift: true }, - G: { key: "KeyG", shift: true }, - H: { key: "KeyH", shift: true }, - I: { key: "KeyI", shift: true }, - Ï: { key: "KeyI", shift: true, accentKey: keyTrema }, - Í: { key: "KeyI", shift: true, accentKey: keyAcute }, - Î: { key: "KeyI", shift: true, accentKey: keyHat }, - Ì: { key: "KeyI", shift: true, accentKey: keyGrave }, - Ĩ: { key: "KeyI", shift: true, accentKey: keyTilde }, - J: { key: "KeyJ", shift: true }, - K: { key: "KeyK", shift: true }, - L: { key: "KeyL", shift: true }, - M: { key: "KeyM", shift: true }, - N: { key: "KeyN", shift: true }, - O: { key: "KeyO", shift: true }, - Ó: { key: "KeyO", shift: true, accentKey: keyAcute }, - Ô: { key: "KeyO", shift: true, accentKey: keyHat }, - Ò: { key: "KeyO", shift: true, accentKey: keyGrave }, - Õ: { key: "KeyO", shift: true, accentKey: keyTilde }, - P: { key: "KeyP", shift: true }, - Q: { key: "KeyQ", shift: true }, - R: { key: "KeyR", shift: true }, - S: { key: "KeyS", shift: true }, - T: { key: "KeyT", shift: true }, - U: { key: "KeyU", shift: true }, - Ü: { key: "KeyU", shift: true, accentKey: keyTrema }, - Ú: { key: "KeyU", shift: true, accentKey: keyAcute }, - Û: { key: "KeyU", shift: true, accentKey: keyHat }, - Ù: { key: "KeyU", shift: true, accentKey: keyGrave }, - Ũ: { key: "KeyU", shift: true, accentKey: keyTilde }, - V: { key: "KeyV", shift: true }, - W: { key: "KeyW", shift: true }, - X: { key: "KeyX", shift: true }, - Y: { key: "KeyY", shift: true }, - Z: { key: "KeyZ", shift: true }, - a: { key: "KeyA" }, - á: { key: "KeyA", accentKey: keyAcute }, - â: { key: "KeyA", accentKey: keyHat }, - à: { key: "KeyA", accentKey: keyGrave }, - ã: { key: "KeyA", accentKey: keyTilde }, - b: { key: "KeyB" }, - c: { key: "KeyC" }, - d: { key: "KeyD" }, - e: { key: "KeyE" }, - ë: { key: "KeyE", accentKey: keyTrema }, - é: { key: "KeyE", accentKey: keyAcute }, - ê: { key: "KeyE", accentKey: keyHat }, - è: { key: "KeyE", accentKey: keyGrave }, - ẽ: { key: "KeyE", accentKey: keyTilde }, - "€": { key: "KeyE", altRight: true }, - f: { key: "KeyF" }, - g: { key: "KeyG" }, - h: { key: "KeyH" }, - i: { key: "KeyI" }, - ï: { key: "KeyI", accentKey: keyTrema }, - í: { key: "KeyI", accentKey: keyAcute }, - î: { key: "KeyI", accentKey: keyHat }, - ì: { key: "KeyI", accentKey: keyGrave }, - ĩ: { key: "KeyI", accentKey: keyTilde }, - j: { key: "KeyJ" }, - k: { key: "KeyK" }, - l: { key: "KeyL" }, - m: { key: "KeyM" }, - n: { key: "KeyN" }, - o: { key: "KeyO" }, - ó: { key: "KeyO", accentKey: keyAcute }, - ô: { key: "KeyO", accentKey: keyHat }, - ò: { key: "KeyO", accentKey: keyGrave }, - õ: { key: "KeyO", accentKey: keyTilde }, - p: { key: "KeyP" }, - q: { key: "KeyQ" }, - r: { key: "KeyR" }, - s: { key: "KeyS" }, - t: { key: "KeyT" }, - u: { key: "KeyU" }, - ü: { key: "KeyU", accentKey: keyTrema }, - ú: { key: "KeyU", accentKey: keyAcute }, - û: { key: "KeyU", accentKey: keyHat }, - ù: { key: "KeyU", accentKey: keyGrave }, - ũ: { key: "KeyU", accentKey: keyTilde }, - v: { key: "KeyV" }, - w: { key: "KeyW" }, - x: { key: "KeyX" }, - y: { key: "KeyY" }, - z: { key: "KeyZ" }, - "§": { key: "Backquote" }, - "½": { key: "Backquote", shift: true }, - 1: { key: "Digit1" }, - "!": { key: "Digit1", shift: true }, - 2: { key: "Digit2" }, - '"': { key: "Digit2", shift: true }, - "@": { key: "Digit2", altRight: true }, - 3: { key: "Digit3" }, - "#": { key: "Digit3", shift: true }, - "£": { key: "Digit3", altRight: true }, - 4: { key: "Digit4" }, - "¤": { key: "Digit4", shift: true }, - $: { key: "Digit4", altRight: true }, - 5: { key: "Digit5" }, - "%": { key: "Digit5", shift: true }, - 6: { key: "Digit6" }, - "&": { key: "Digit6", shift: true }, - 7: { key: "Digit7" }, - "/": { key: "Digit7", shift: true }, - "{": { key: "Digit7", altRight: true }, - 8: { key: "Digit8" }, - "(": { key: "Digit8", shift: true }, - "[": { key: "Digit8", altRight: true }, - 9: { key: "Digit9" }, - ")": { key: "Digit9", shift: true }, - "]": { key: "Digit9", altRight: true }, - 0: { key: "Digit0" }, - "=": { key: "Digit0", shift: true }, - "}": { key: "Digit0", altRight: true }, - "+": { key: "Minus" }, - "?": { key: "Minus", shift: true }, - "\\": { key: "Minus", altRight: true }, - å: { key: "BracketLeft" }, - Å: { key: "BracketLeft", shift: true }, - ö: { key: "Semicolon" }, - Ö: { key: "Semicolon", shift: true }, - ä: { key: "Quote" }, - Ä: { key: "Quote", shift: true }, - "'": { key: "Backslash" }, - "*": { key: "Backslash", shift: true }, - ",": { key: "Comma" }, - ";": { key: "Comma", shift: true }, - ".": { key: "Period" }, - ":": { key: "Period", shift: true }, - "-": { key: "Slash" }, - _: { key: "Slash", shift: true }, - "<": { key: "IntlBackslash" }, - ">": { key: "IntlBackslash", shift: true }, - "|": { key: "IntlBackslash", altRight: true }, - " ": { key: "Space" }, - "\n": { key: "Enter" }, - Enter: { key: "Enter" }, - Tab: { key: "Tab" }, -} as Record; - -export const sv_SE: KeyboardLayout = { - isoCode: isoCode, - name: name, - chars: chars, - // TODO need to localize these maps and layouts - keyDisplayMap: en_US.keyDisplayMap, - modifierDisplayMap: en_US.modifierDisplayMap, - virtualKeyboard: en_US.virtualKeyboard, -}; diff --git a/ui/src/keyboardMappings.ts b/ui/src/keyboardMappings.ts index 47d4fda7d..ef04ab482 100644 --- a/ui/src/keyboardMappings.ts +++ b/ui/src/keyboardMappings.ts @@ -235,24 +235,6 @@ export const keys = { VolumeUp: 0x80, } as Record; -export const deadKeys = { - Acute: 0x00b4, - Breve: 0x02d8, - Caron: 0x02c7, - Cedilla: 0x00b8, - Circumflex: 0x005e, // or 0x02c6? - Comma: 0x002c, - Dot: 0x00b7, - DoubleAcute: 0x02dd, - Grave: 0x0060, - Kreis: 0x00b0, - Ogonek: 0x02db, - Ring: 0x02da, - Slash: 0x02f8, - Tilde: 0x007e, - Umlaut: 0x00a8, -} as Record; - export const modifiers = { ControlLeft: 0x01, ControlRight: 0x10, @@ -276,14 +258,13 @@ export const hidKeyToModifierMask = { 0xe7: modifiers.MetaRight, } as Record; -export const latchingKeys = ["CapsLock", "ScrollLock", "NumLock", "Meta", "Compose", "Kana"]; +/** HID modifier scancodes occupy the 0xE0–0xE7 range */ +export const isModifierScancode = (scancode: number) => scancode >= 0xe0 && scancode <= 0xe7; + +/** Modifier key names from the `modifiers` map (excludes the AltGr alias) */ +export const modifierKeyNames = Object.keys(modifiers).filter(n => n !== "AltGr"); -export function decodeModifiers(modifier: number) { - return { - isShiftActive: (modifier & (modifiers.ShiftLeft | modifiers.ShiftRight)) !== 0, - isControlActive: (modifier & (modifiers.ControlLeft | modifiers.ControlRight)) !== 0, - isAltActive: (modifier & (modifiers.AltLeft | modifiers.AltRight)) !== 0, - isMetaActive: (modifier & (modifiers.MetaLeft | modifiers.MetaRight)) !== 0, - isAltGrActive: (modifier & modifiers.AltGr) !== 0, - }; -} +// Scancode-to-control-like classification is computed in Go and shipped on +// each TransportKey as `controlLike` — read it from there. See +// docs/keyboard/TRANSPORT.md and internal/keyboard/scancode.go +// (IsControlScancode / ScancodeProducesText). diff --git a/ui/src/routes/devices.$id.deregister.tsx b/ui/src/routes/devices.$id.deregister.tsx index 3e691019c..b1038abd7 100644 --- a/ui/src/routes/devices.$id.deregister.tsx +++ b/ui/src/routes/devices.$id.deregister.tsx @@ -23,7 +23,9 @@ interface LoaderData { } const action: ActionFunction = async ({ request }: ActionFunctionArgs) => { - const { deviceId } = Object.fromEntries(await request.formData()); + const formData = await request.formData(); + const rawDeviceId = formData.get("deviceId"); + const deviceId = typeof rawDeviceId === "string" ? rawDeviceId : ""; try { const res = await fetch(`${CLOUD_API}/devices/${deviceId}`, { diff --git a/ui/src/routes/devices.$id.settings.keyboard.tsx b/ui/src/routes/devices.$id.settings.keyboard.tsx index f6db269b2..3339ad9f2 100644 --- a/ui/src/routes/devices.$id.settings.keyboard.tsx +++ b/ui/src/routes/devices.$id.settings.keyboard.tsx @@ -1,49 +1,128 @@ -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { LuTrash2, LuUpload, LuRefreshCw, LuEye, LuCheck, LuChevronsUpDown } from "react-icons/lu"; +import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from "@headlessui/react"; +import { cx } from "@/cva.config"; import { useSettingsStore } from "@hooks/stores"; import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc"; -import useKeyboardLayout from "@hooks/useKeyboardLayout"; +import { Button } from "@components/Button"; import { Checkbox } from "@components/Checkbox"; -import { SelectMenuBasic } from "@components/SelectMenuBasic"; +import { ConfirmDialog } from "@components/ConfirmDialog"; import { SettingsItem } from "@components/SettingsItem"; import { SettingsPageHeader } from "@components/SettingsPageheader"; +import { useKleUpload } from "@components/keyboard/useKleUpload"; +import { LayoutPreviewDialog } from "@components/keyboard/LayoutPreviewDialog"; +import type { LayoutMeta } from "@components/keyboard/types/schema"; import notifications from "@/notifications"; import { m } from "@localizations/messages.js"; +const FALLBACK_LAYOUT = "en-US"; + export default function SettingsKeyboardRoute() { - const { setKeyboardLayout } = useSettingsStore(); + const { keyboardLayout, setKeyboardLayout } = useSettingsStore(); const { showPressedKeys, setShowPressedKeys } = useSettingsStore(); - const { selectedKeyboard, keyboardOptions } = useKeyboardLayout(); - + const { modifierLatching, setModifierLatching } = useSettingsStore(); const { send } = useJsonRpc(); + const [layouts, setLayouts] = useState([]); + const [deleteTarget, setDeleteTarget] = useState(null); + const [previewLayoutId, setPreviewLayoutId] = useState(null); + + const { + result: uploadResult, + error: uploadError, + openFilePicker, + clear: clearUpload, + } = useKleUpload(); + + const refreshLayouts = useCallback(() => { + void send("getKeyboardLayouts", {}, (resp: JsonRpcResponse) => { + if ("error" in resp) return; + setLayouts(resp.result as LayoutMeta[]); + }); + }, [send]); + + // Fetch the active layout ID from config and the available layouts list useEffect(() => { - send("getKeyboardLayout", {}, (resp: JsonRpcResponse) => { + void send("getKeyboardLayout", {}, (resp: JsonRpcResponse) => { if ("error" in resp) return; - const isoCode = resp.result as string; - console.log("Fetched keyboard layout from backend:", isoCode); - if (isoCode && isoCode.length > 0) { - setKeyboardLayout(isoCode); + const id = resp.result as string; + if (id && id.length > 0) { + setKeyboardLayout(id); } }); - }, [send, setKeyboardLayout]); + refreshLayouts(); + }, [send, setKeyboardLayout, refreshLayouts]); + + // Handle upload result — open preview dialog + useEffect(() => { + if (uploadResult) { + notifications.success( + m.keyboard_layout_custom_upload_success({ + name: uploadResult.name, + keys: uploadResult.keyCount, + }), + ); + if (uploadResult.warnings?.length) { + for (const warning of uploadResult.warnings) { + notifications.error(warning, { duration: 8000 }); + } + } + setPreviewLayoutId(uploadResult.id); + clearUpload(); + refreshLayouts(); + } + if (uploadError) { + notifications.error(m.keyboard_layout_custom_upload_error({ error: uploadError })); + clearUpload(); + } + }, [uploadResult, uploadError, clearUpload, refreshLayouts]); + + const customLayouts = useMemo(() => layouts.filter(l => !l.builtin), [layouts]); const onKeyboardLayoutChange = useCallback( - (e: React.ChangeEvent) => { - const isoCode = e.target.value; - send("setKeyboardLayout", { layout: isoCode }, resp => { + (id: string) => { + void send("setKeyboardLayout", { layout: id }, resp => { if ("error" in resp) { notifications.error( m.keyboard_layout_error({ error: resp.error.data || m.unknown_error() }), ); + return; } - notifications.success(m.keyboard_layout_success({ layout: isoCode })); - setKeyboardLayout(isoCode); + const layoutName = layouts.find(l => l.id === id)?.name ?? id; + notifications.success(m.keyboard_layout_success({ layout: layoutName })); + setKeyboardLayout(id); }); }, - [send, setKeyboardLayout], + [send, setKeyboardLayout, layouts], ); + const handleDeleteLayout = useCallback(() => { + if (!deleteTarget) return; + const { id, name } = deleteTarget; + + void send("deleteKeyboardLayout", { id }, resp => { + if ("error" in resp) { + notifications.error( + m.keyboard_layout_delete_error({ error: resp.error.data || m.unknown_error() }), + ); + setDeleteTarget(null); + return; + } + + notifications.success(m.keyboard_layout_delete_success({ name })); + setDeleteTarget(null); + refreshLayouts(); + + // If the deleted layout was the active one, switch to fallback + if (keyboardLayout === id) { + void send("setKeyboardLayout", { layout: FALLBACK_LAYOUT }, () => { + setKeyboardLayout(FALLBACK_LAYOUT); + }); + } + }); + }, [deleteTarget, send, refreshLayouts, keyboardLayout, setKeyboardLayout]); + return (
@@ -53,14 +132,50 @@ export default function SettingsKeyboardRoute() { title={m.keyboard_layout_title()} description={m.keyboard_layout_description()} > - + +
+ + + {layouts.find(l => l.id === keyboardLayout)?.name ?? keyboardLayout} + + + + + + + {layouts.map(layout => ( + + + + + {layout.name} + + ))} + +
+

{m.keyboard_layout_long_description()} @@ -68,6 +183,80 @@ export default function SettingsKeyboardRoute() {

+
+ +
+ {customLayouts.length > 0 ? ( +
+ {customLayouts.map(layout => ( +
+ + {layout.name} + + {layout.id} + + +
+
+
+ ))} +
+ ) : ( +

+ {m.keyboard_layout_none_custom()} +

+ )} +
+ +
+ + setModifierLatching(e.target.checked)} + /> +
+ + setDeleteTarget(null)} + title={m.keyboard_layout_delete_confirm_title()} + description={m.keyboard_layout_delete_confirm_description({ + name: deleteTarget?.name ?? "", + })} + variant="danger" + confirmText={m.delete()} + onConfirm={handleDeleteLayout} + /> + + setPreviewLayoutId(null)} />
); } diff --git a/ui/src/routes/devices.$id.settings.macros.tsx b/ui/src/routes/devices.$id.settings.macros.tsx index c5e2871d1..e918c12cd 100644 --- a/ui/src/routes/devices.$id.settings.macros.tsx +++ b/ui/src/routes/devices.$id.settings.macros.tsx @@ -11,8 +11,10 @@ import { LuCommand, } from "react-icons/lu"; -import { KeySequence, useMacrosStore, generateMacroId } from "@hooks/stores"; -import useKeyboardLayout from "@hooks/useKeyboardLayout"; +import { KeySequence, useMacrosStore, useSettingsStore, generateMacroId } from "@hooks/stores"; +import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc"; +import type { KeyboardLayout } from "@components/keyboard/types/schema"; +import { buildKeyDisplayMap, modifierDisplayMap } from "@/keyDisplayNames"; import { SettingsPageHeader } from "@components/SettingsPageheader"; import { Button } from "@components/Button"; import Card from "@components/Card"; @@ -30,7 +32,20 @@ export default function SettingsMacrosRoute() { const [actionLoadingId, setActionLoadingId] = useState(null); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [macroToDelete, setMacroToDelete] = useState(null); - const { selectedKeyboard } = useKeyboardLayout(); + + const { send } = useJsonRpc(); + const { keyboardLayout } = useSettingsStore(); + const [kleLayout, setKleLayout] = useState(null); + + useEffect(() => { + if (!keyboardLayout) return; + void send("getKeyboardLayoutData", { id: keyboardLayout }, (resp: JsonRpcResponse) => { + if ("error" in resp) return; + setKleLayout(resp.result as KeyboardLayout); + }); + }, [send, keyboardLayout]); + + const keyDisplayMap = useMemo(() => buildKeyDisplayMap(kleLayout), [kleLayout]); const isMaxMacrosReached = useMemo(() => macros.length >= MAX_TOTAL_MACROS, [macros.length]); @@ -149,6 +164,7 @@ export default function SettingsMacrosRoute() { onClick={() => handleMoveMacro(index, "up", macro.id)} disabled={index === 0 || actionLoadingId === macro.id} LeadingIcon={LuArrowUp} + data-testid={`macro-move-up-${macro.id}`} aria-label={m.macros_aria_move_up({ name: macro.name })} />
@@ -181,7 +198,7 @@ export default function SettingsMacrosRoute() { step.modifiers.map((modifier, idx) => ( - {selectedKeyboard.modifierDisplayMap[modifier] || modifier} + {modifierDisplayMap[modifier] || modifier} {idx < step.modifiers.length - 1 && ( @@ -204,7 +221,7 @@ export default function SettingsMacrosRoute() { step.keys.map((key, idx) => ( - {selectedKeyboard.keyDisplayMap[key] || key} + {keyDisplayMap[key] || key} {idx < step.keys.length - 1 && ( @@ -243,6 +260,7 @@ export default function SettingsMacrosRoute() { setShowDeleteConfirm(true); }} disabled={actionLoadingId === macro.id} + data-testid={`macro-delete-${macro.id}`} aria-label={m.macros_aria_delete({ name: macro.name })} />
@@ -292,9 +312,8 @@ export default function SettingsMacrosRoute() { actionLoadingId, handleDeleteMacro, handleMoveMacro, - selectedKeyboard.modifierDisplayMap, - selectedKeyboard.keyDisplayMap, handleDuplicateMacro, + keyDisplayMap, navigate, ], ); @@ -311,6 +330,7 @@ export default function SettingsMacrosRoute() { text={isMaxMacrosReached ? m.macros_max_reached() : m.macros_add_new_macro()} onClick={() => navigate("add")} disabled={isMaxMacrosReached} + data-testid="macro-add-new" aria-label={m.macros_aria_add_new()} />
@@ -340,6 +360,7 @@ export default function SettingsMacrosRoute() { text={m.macros_add_new_macro()} onClick={() => navigate("add")} disabled={isMaxMacrosReached} + data-testid="macro-add-new-empty" aria-label={m.macros_aria_add_new()} /> } diff --git a/ui/src/routes/devices.$id.setup.tsx b/ui/src/routes/devices.$id.setup.tsx index 10f72a4cc..53a23d2d0 100644 --- a/ui/src/routes/devices.$id.setup.tsx +++ b/ui/src/routes/devices.$id.setup.tsx @@ -34,10 +34,19 @@ const loader: LoaderFunction = async ({ params }: LoaderFunctionArgs) => { }; const action: ActionFunction = async ({ request }: ActionFunctionArgs) => { - // Handle form submission - const { name, id, returnTo } = Object.fromEntries(await request.formData()); + // Handle form submission. FormData entries are FormDataEntryValue (string|File); + // these three are always strings, but narrow explicitly so url interpolation + // can't accidentally stringify a File. + const formData = await request.formData(); + const stringField = (k: string): string => { + const v = formData.get(k); + return typeof v === "string" ? v : ""; + }; + const name = stringField("name"); + const id = stringField("id"); + const returnTo = stringField("returnTo"); - if (!name || name === "") { + if (!name) { return { message: m.register_device_no_name() }; } @@ -45,7 +54,7 @@ const action: ActionFunction = async ({ request }: ActionFunctionArgs) => { const res = await api.PUT(`${CLOUD_API}/devices/${id}`, { name }); if (res.ok) { - return redirect(returnTo?.toString() ?? `/devices/${id}`); + return redirect(returnTo || `/devices/${id}`); } else { return { error: m.register_device_error({ error: res.statusText }) }; } diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index 81b710d53..50f5e3e01 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -126,9 +126,7 @@ function stripH265FromVideoTransceivers(pc: RTCPeerConnection) { if (t.receiver.track?.kind !== "video") continue; t.setCodecPreferences(filtered); } - console.debug( - "[setupPeerConnection] Linux: stripped H.265 from video codec preferences", - ); + console.debug("[setupPeerConnection] Linux: stripped H.265 from video codec preferences"); } catch (e) { console.warn("[setupPeerConnection] setCodecPreferences failed", e); } @@ -475,7 +473,7 @@ export default function KvmIdRoute() { console.debug("[setupPeerConnection] Peer connection created", pc); setLoadingMessage(m.setting_up_connection_to_device()); } catch (e) { - console.error(`[setupPeerConnection] Error creating peer connection: ${e}`); + console.error(`[setupPeerConnection] Error creating peer connection: ${String(e)}`); setTimeout(() => { cleanupAndStopReconnecting(); }, 1000); @@ -507,7 +505,10 @@ export default function KvmIdRoute() { console.log("Legacy signaling. Waiting for ICE Gathering to complete..."); } } catch (e) { - console.error(`[setupPeerConnection] Error creating offer: ${e}`, new Date().toISOString()); + console.error( + `[setupPeerConnection] Error creating offer: ${String(e)}`, + new Date().toISOString(), + ); cleanupAndStopReconnecting(); } finally { makingOffer.current = false; @@ -548,7 +549,7 @@ export default function KvmIdRoute() { setRpcDataChannel(null); }; rpcDataChannel.onerror = (ev: Event) => - console.error(`Error on DataChannel '${rpcDataChannel.label}': ${ev}`); + console.error(`Error on DataChannel '${rpcDataChannel.label}': ${ev.type}`); rpcDataChannel.onopen = () => { setRpcDataChannel(rpcDataChannel); }; @@ -557,7 +558,7 @@ export default function KvmIdRoute() { rpcHidChannel.binaryType = "arraybuffer"; rpcHidChannel.onclose = () => console.log("rpcHidChannel has closed"); rpcHidChannel.onerror = (ev: Event) => - console.error(`Error on rpcHidChannel '${rpcHidChannel.label}': ${ev}`); + console.error(`Error on rpcHidChannel '${rpcHidChannel.label}': ${ev.type}`); rpcHidChannel.onopen = () => { setRpcHidChannel(rpcHidChannel); }; @@ -570,7 +571,9 @@ export default function KvmIdRoute() { rpcHidUnreliableChannel.binaryType = "arraybuffer"; rpcHidUnreliableChannel.onclose = () => console.log("rpcHidUnreliableChannel has closed"); rpcHidUnreliableChannel.onerror = (ev: Event) => - console.error(`Error on rpcHidUnreliableChannel '${rpcHidUnreliableChannel.label}': ${ev}`); + console.error( + `Error on rpcHidUnreliableChannel '${rpcHidUnreliableChannel.label}': ${ev.type}`, + ); rpcHidUnreliableChannel.onopen = () => { setRpcHidUnreliableChannel(rpcHidUnreliableChannel); }; @@ -584,7 +587,7 @@ export default function KvmIdRoute() { console.log("rpcHidUnreliableNonOrderedChannel has closed"); rpcHidUnreliableNonOrderedChannel.onerror = (ev: Event) => console.error( - `Error on rpcHidUnreliableNonOrderedChannel '${rpcHidUnreliableNonOrderedChannel.label}': ${ev}`, + `Error on rpcHidUnreliableNonOrderedChannel '${rpcHidUnreliableNonOrderedChannel.label}': ${ev.type}`, ); rpcHidUnreliableNonOrderedChannel.onopen = () => { setRpcHidUnreliableNonOrderedChannel(rpcHidUnreliableNonOrderedChannel); @@ -594,7 +597,7 @@ export default function KvmIdRoute() { const terminalDataChannel = pc.createDataChannel("terminal"); terminalDataChannel.onclose = () => console.log("terminalDataChannel has closed"); terminalDataChannel.onerror = (ev: Event) => - console.error(`Error on terminalDataChannel '${terminalDataChannel.label}': ${ev}`); + console.error(`Error on terminalDataChannel '${terminalDataChannel.label}': ${ev.type}`); terminalDataChannel.onopen = () => { setTerminalChannel(terminalDataChannel); }; @@ -713,7 +716,7 @@ export default function KvmIdRoute() { const { setFailsafeMode } = useFailsafeModeStore(); // Keyboard handler for E2E tests - const { handleKeyPress, pauseKeepAlive } = useKeyboard(); + const { handleKeyPress, pauseKeepAlive, executeHidMacro, cancelExecuteMacro } = useKeyboard(); // Mouse handler for E2E tests const { reportAbsMouseEvent, rpcHidReady } = useHidRpc(); @@ -929,6 +932,8 @@ export default function KvmIdRoute() { handleKeyPress, pauseKeepAlive, handleAbsMouseMove, + executeHidMacro, + cancelExecuteMacro, getKeyboardLedState: () => useHidStore.getState().keyboardLedState, getKeysDownState: () => useHidStore.getState().keysDownState, getPeerConnectionState: () => useRTCStore.getState().peerConnectionState, @@ -941,7 +946,7 @@ export default function KvmIdRoute() { getPeerConnection: () => useRTCStore.getState().peerConnection, }); return cleanupTestHooks; - }, [handleKeyPress, pauseKeepAlive, handleAbsMouseMove]); + }, [handleKeyPress, pauseKeepAlive, handleAbsMouseMove, executeHidMacro, cancelExecuteMacro]); const outlet = useOutlet(); const onModalClose = useCallback(() => { diff --git a/ui/src/test/testHooks.ts b/ui/src/test/testHooks.ts index aaafe2877..99ab4bf51 100644 --- a/ui/src/test/testHooks.ts +++ b/ui/src/test/testHooks.ts @@ -9,12 +9,15 @@ */ import { KeyboardLedState, KeysDownState } from "@/hooks/stores"; +import { KeyboardMacroStep } from "@/hooks/hidRpc"; /** Internal handlers set by React components (prefixed with _ to indicate internal use) */ interface TestHooksInternal { _handleKeyPress?: (key: number, press: boolean) => void; _pauseKeepAlive?: (ms: number) => void; _handleAbsMouseMove?: (x: number, y: number, buttons: number) => void; + _executeHidMacro?: (steps: KeyboardMacroStep[]) => Promise; + _cancelExecuteMacro?: () => Promise; _getKeyboardLedState?: () => KeyboardLedState; _getKeysDownState?: () => KeysDownState; _getPeerConnectionState?: () => RTCPeerConnectionState | null; @@ -36,6 +39,13 @@ export interface KvmTestHooks extends TestHooksInternal { */ pauseKeepAlive: (ms: number) => void; sendAbsMouseMove: (x: number, y: number, buttons: number) => void; + /** + * Test-only: send a pre-built scancode-based macro through the same path + * the PasteModal uses (executeHidMacro → hidrpc → device → host). + */ + executeHidMacro: (steps: KeyboardMacroStep[]) => Promise; + /** Test-only: cancel an in-flight macro/paste. */ + cancelExecuteMacro: () => Promise; sendJsonRpc: ( method: string, params: Record, @@ -118,6 +128,22 @@ export function initTestHooks(): void { } }, + executeHidMacro: async (steps: KeyboardMacroStep[]) => { + if (hooks._executeHidMacro) { + await hooks._executeHidMacro(steps); + } else { + console.warn("[E2E] executeHidMacro called but no handler registered"); + } + }, + + cancelExecuteMacro: async () => { + if (hooks._cancelExecuteMacro) { + await hooks._cancelExecuteMacro(); + } else { + console.warn("[E2E] cancelExecuteMacro called but no handler registered"); + } + }, + sendJsonRpc: ( method: string, params: Record, @@ -336,6 +362,8 @@ export function registerTestHandlers(handlers: { handleKeyPress: (key: number, press: boolean) => void; pauseKeepAlive: (ms: number) => void; handleAbsMouseMove: (x: number, y: number, buttons: number) => void; + executeHidMacro: (steps: KeyboardMacroStep[]) => Promise; + cancelExecuteMacro: () => Promise; getKeyboardLedState: () => KeyboardLedState; getKeysDownState: () => KeysDownState; getPeerConnectionState: () => RTCPeerConnectionState | null; @@ -352,6 +380,8 @@ export function registerTestHandlers(handlers: { window.__kvmTestHooks._handleKeyPress = handlers.handleKeyPress; window.__kvmTestHooks._pauseKeepAlive = handlers.pauseKeepAlive; window.__kvmTestHooks._handleAbsMouseMove = handlers.handleAbsMouseMove; + window.__kvmTestHooks._executeHidMacro = handlers.executeHidMacro; + window.__kvmTestHooks._cancelExecuteMacro = handlers.cancelExecuteMacro; window.__kvmTestHooks._getKeyboardLedState = handlers.getKeyboardLedState; window.__kvmTestHooks._getKeysDownState = handlers.getKeysDownState; window.__kvmTestHooks._getPeerConnectionState = handlers.getPeerConnectionState; @@ -373,6 +403,8 @@ export function cleanupTestHooks(): void { window.__kvmTestHooks._handleKeyPress = undefined; window.__kvmTestHooks._pauseKeepAlive = undefined; window.__kvmTestHooks._handleAbsMouseMove = undefined; + window.__kvmTestHooks._executeHidMacro = undefined; + window.__kvmTestHooks._cancelExecuteMacro = undefined; window.__kvmTestHooks._getKeyboardLedState = undefined; window.__kvmTestHooks._getKeysDownState = undefined; window.__kvmTestHooks._getPeerConnectionState = undefined; diff --git a/ui/tools/find_unused_messages.py b/ui/tools/find_unused_messages.py index f83683bf8..ae1aedfc4 100644 --- a/ui/tools/find_unused_messages.py +++ b/ui/tools/find_unused_messages.py @@ -3,7 +3,7 @@ import json import os import re -from datetime import datetime +import datetime from pathlib import Path def flatten(d, prefix=""): @@ -82,7 +82,7 @@ def main(): print(f"Generating report for {len(usages)} usages ...") report = { - "generated_at": datetime.utcnow().isoformat() + "Z", + "generated_at": datetime.datetime.now(datetime.timezone.utc).isoformat() + "Z", "en_json": str(en_path), "src_root": args.src, "total_keys": len(keys), @@ -94,7 +94,8 @@ def main(): used = bool(occ) report["keys"][k] = {"used": used, "occurrences": occ} - unused_keys = [k for k, v in report["keys"].items() if not v["used"]] + # ignore keys starting with locale_ as they are computed dynamically for language selection + unused_keys = [k for k, v in report["keys"].items() if not v["used"] and not k.startswith("locale_")] unused_count = len(unused_keys) print(f"Found {unused_count} unused keys") diff --git a/ui/tsconfig.app.json b/ui/tsconfig.app.json index 1320517df..71a13fb21 100644 --- a/ui/tsconfig.app.json +++ b/ui/tsconfig.app.json @@ -52,6 +52,7 @@ } }, "include": [ - "src" + "src", + "../internal/keyboard/keyaliases.json" ] } \ No newline at end of file diff --git a/ui/vite.config.ts b/ui/vite.config.ts index d8aedcee9..31ef6ab30 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -64,6 +64,9 @@ export default defineConfig(({ mode, command }) => { server: { host: "0.0.0.0", https: useSSL, + // Keycap.tsx imports the shared key-alias taxonomy at + // ../../internal/keyboard/keyaliases.json (single source of truth with Go). + fs: { allow: [".", ".."] }, proxy: JETKVM_PROXY_URL ? { "/me": JETKVM_PROXY_URL, diff --git a/web.go b/web.go index 1e1bf796f..11fa867f7 100644 --- a/web.go +++ b/web.go @@ -25,6 +25,7 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/jetkvm/kvm/internal/diagnostics" + "github.com/jetkvm/kvm/internal/keyboard" "github.com/jetkvm/kvm/internal/logging" "github.com/jetkvm/kvm/internal/supervisor" "github.com/pion/webrtc/v4" @@ -220,6 +221,8 @@ func setupRouter() *gin.Engine { protected.POST("/device/send-wol/:mac-addr", handleSendWOLMagicPacket) + protected.POST("/keyboard/upload", keyboard.HandleKeyboardUpload) + protected.GET("/diagnostics", handleDiagnosticsDownload) }