feat: initial release of SplitOverlay (KeyOverlay 1.0.0)
CI / build-and-test (push) Canceled after 0s

Semi-transparent always-on-top Windows overlay that shows the keymap of a
Vial/QMK split keyboard (Silakka54).

- draws all layers from a Vial .vil layout, transparent keys fall through
- detects the active layer from the keyboard via a Raw HID command (firmware/)
- shows characters of the active Windows input language
- can split the two halves into separate, independently movable windows
- highlights pressed keys; tray menu for opacity, size and options
- Inno Setup installer and portable build; GitHub-style CI workflow
This commit is contained in:
2026-10-20 09:43:17 +03:00
commit 292d61bb47
48 changed files with 2952 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
* text=auto
*.sh text eol=lf
*.iss text eol=crlf
*.uf2 binary
*.ico binary
*.png binary
+19
View File
@@ -0,0 +1,19 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
build-and-test:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 8.0.x
- name: Test core library
run: dotnet test tests/KeyOverlay.Core.Tests --configuration Release
- name: Build overlay app
run: dotnet build src/KeyOverlay --configuration Release
+7
View File
@@ -0,0 +1,7 @@
bin/
obj/
dist
installer/packages
.firmware-work/
*.uf2
*.bak
+43
View File
@@ -0,0 +1,43 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{0ADB070F-AB8F-4DA6-BD33-5001267A6343}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KeyOverlay.Core", "src\KeyOverlay.Core\KeyOverlay.Core.csproj", "{68EC4B7C-AAB7-48CC-B8CB-98082034E098}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KeyOverlay", "src\KeyOverlay\KeyOverlay.csproj", "{E6CF8FCD-871E-4117-BFEC-32CC4B4A4954}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{8477236C-87BB-422F-8965-2654596DCE5B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KeyOverlay.Core.Tests", "tests\KeyOverlay.Core.Tests\KeyOverlay.Core.Tests.csproj", "{CFCA730A-BBC2-408B-8231-91BC2B279D36}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{68EC4B7C-AAB7-48CC-B8CB-98082034E098}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{68EC4B7C-AAB7-48CC-B8CB-98082034E098}.Debug|Any CPU.Build.0 = Debug|Any CPU
{68EC4B7C-AAB7-48CC-B8CB-98082034E098}.Release|Any CPU.ActiveCfg = Release|Any CPU
{68EC4B7C-AAB7-48CC-B8CB-98082034E098}.Release|Any CPU.Build.0 = Release|Any CPU
{E6CF8FCD-871E-4117-BFEC-32CC4B4A4954}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E6CF8FCD-871E-4117-BFEC-32CC4B4A4954}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E6CF8FCD-871E-4117-BFEC-32CC4B4A4954}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E6CF8FCD-871E-4117-BFEC-32CC4B4A4954}.Release|Any CPU.Build.0 = Release|Any CPU
{CFCA730A-BBC2-408B-8231-91BC2B279D36}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CFCA730A-BBC2-408B-8231-91BC2B279D36}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CFCA730A-BBC2-408B-8231-91BC2B279D36}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CFCA730A-BBC2-408B-8231-91BC2B279D36}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{68EC4B7C-AAB7-48CC-B8CB-98082034E098} = {0ADB070F-AB8F-4DA6-BD33-5001267A6343}
{E6CF8FCD-871E-4117-BFEC-32CC4B4A4954} = {0ADB070F-AB8F-4DA6-BD33-5001267A6343}
{CFCA730A-BBC2-408B-8231-91BC2B279D36} = {8477236C-87BB-422F-8965-2654596DCE5B}
EndGlobalSection
EndGlobal
+24
View File
@@ -0,0 +1,24 @@
MIT License
Copyright (c) 2026 KeyOverlay contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Note: the firmware add-on in firmware/keyoverlay_hid.c is built into QMK/Vial
firmware and is licensed under GPL-2.0-or-later (see the SPDX header in that file).
+193
View File
@@ -0,0 +1,193 @@
# SplitOverlay
**Русский** · [English below](#english)
> Название репозитория — **SplitOverlay**. В коде, в имени exe, установщика и в папке настроек приложение называется **KeyOverlay**.
Полупрозрачный оверлей для Windows, который поверх всех окон показывает раскладку программируемой
(Vial / QMK) split-клавиатуры, например [Silakka54](https://github.com/Squalius-cephalus/silakka54).
Нужен, чтобы учить слои: смотришь на экран и видишь, что где лежит.
![Слой 0, нажаты Shift+1](docs/screenshot-layer0.png)
## Возможности
- Рисует плату по файлу раскладки Vial (`.vil`): все слои, `MO`/`TG`/`LT`/mod-tap, `Shift+цифра` и т.д.
- Прозрачные клавиши слоя (`KC_TRNS`) показываются приглушённо с нижнего слоя, как в QMK.
- **Автоопределение слоя с платы** (нужна прошивка с командой из [`firmware/`](firmware/)); без неё слой переключается вручную.
- **Символы по языку ввода**: буквы на клавишах меняются вместе с раскладкой Windows (RU, EN и любая другая). Нужные символы выдаёт сама Windows.
- **Разделение половинок**: каждая половина отдельным окном, двигаются независимо.
- Подсветка нажатых клавиш, регулировка прозрачности и размера, значок в трее.
- Оверлей прозрачен для мыши и не забирает фокус.
| Слой 1 | Разделение половинок | Язык ввода |
|---|---|---|
| ![](docs/screenshot-layer1.png) | ![](docs/screenshot-split-left.png) | ![](docs/screenshot-lang-ru.png) |
## Установка
Скачай со страницы [Releases](../../releases) установщик `KeyOverlay-<версия>-setup.exe`
или портативный `KeyOverlay-<версия>-portable.zip`. Установка идёт без прав администратора.
Exe не подписан, поэтому SmartScreen может предупредить («Подробнее» → «Выполнить в любом случае»).
Раскладку сохрани в Vial: **File → Save current layout** (`.vil`). При первом запуске приложение
попросит выбрать этот файл; можно также передать путь аргументом: `KeyOverlay.exe "путь\к\раскладке.vil"`.
## Управление
| Действие | Как |
|---|---|
| Следующий / предыдущий слой | Ctrl+Alt+PgDn / Ctrl+Alt+PgUp (отключает автоопределение) |
| Показать / скрыть | Ctrl+Alt+K или двойной клик по значку в трее |
| Прозрачность, размер, слой, язык, разделение, перечитать файл | меню значка в трее |
| Переместить оверлей | трей → «Режим перемещения», тащить мышью, колесо меняет размер |
## Прошивка (для автоопределения слоя)
Обычная клавиатура не сообщает ПК, какой слой активен, и в протоколе Vial такой команды нет.
Поэтому в [`firmware/`](firmware/) лежит небольшое дополнение для QMK/Vial: команда HID `0xE0`,
которая возвращает активный слой. Подробности, сборка и инструкция по прошивке: [`firmware/README.md`](firmware/README.md).
Готовые `.uf2` для Silakka54 (LED, v1.4led) лежат в Releases.
## Сборка из исходников
Нужен [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) под Windows.
```
git clone https://gittea.khad1997pav.ru/khadzhibekovpa/SplitOverlay.git
cd SplitOverlay
dotnet test tests/KeyOverlay.Core.Tests
dotnet run --project src/KeyOverlay -- "путь\к\раскладке.vil"
```
Установщик и портативная сборка (Inno Setup берётся из NuGet, в систему ничего не ставится):
```
dotnet publish src/KeyOverlay -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=true -o dist/app
dotnet restore installer/tools.csproj
installer/packages/tools.innosetup/6.4.3/tools/ISCC.exe /DAppVersion=1.0.0 installer/KeyOverlay.iss
```
Установщик кладёт в пакет файлы прошивки из `firmware/*.uf2`: соберите их (см. `firmware/README.md`)
или скачайте из Releases и положите в `firmware/`.
## Устройство проекта
```
src/KeyOverlay.Core парсер .vil, подписи кейкодов, геометрия платы, протокол слоя (без зависимости от Windows)
src/KeyOverlay WPF-оверлей: окна, хук клавиатуры, опрос платы по HID, язык ввода, трей
tests/ тесты Core (xUnit)
firmware/ команда слоя для QMK/Vial и сборка прошивки в Docker
installer/ скрипт Inno Setup
```
## Как это работает
**Core** (`src/KeyOverlay.Core`, .NET 8, без зависимости от Windows, покрыт тестами):
- `Keymap` читает `.vil` (JSON): поле `layout` — слои × 10 строк матрицы × 6 колонок. Строки 0–4 — левая половина,
5–9 — правая, `-1` — клавиши нет. Прозрачные клавиши (`KC_TRNS`) проваливаются на нижний слой, как в QMK.
- `Keycodes` превращает кейкоды Vial/QMK в подписи (`KC_BSPACE` → «Bksp», `LSFT(KC_1)` → «!», `MO(2)` → «MO 2»).
- `BoardGeometry` — положение клавиш Silakka54: 4×6 с шагом по колонкам и 3 больших пальца на половину.
- `ScanCodes` и `PressedKeys` — разбор скан-кодов (физическая клавиша не зависит от языка) и учёт нажатых.
- `LayerProtocol` — формат команды слоя для прошивки; `KeyLocalizer` — замена подписей по языку ввода.
**Приложение** (`src/KeyOverlay`, WPF):
- Окно прозрачное, без рамки, поверх всех окон; стили `WS_EX_TRANSPARENT | WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW`
делают его прозрачным для мыши, без фокуса и без значка в панели задач.
- `KeyboardHook` — глобальный low-level hook: подсветка нажатых клавиш и горячие клавиши. События всегда передаются дальше.
- `LayerPoller` — раз в ~80 мс спрашивает плату по Raw HID (usage page `0xFF60`, usage `0x61`, библиотека HidSharp), реконнект при отключении.
- `InputLanguage` — язык берётся у окна на переднем плане (`GetKeyboardLayout`), символы клавиш вычисляет Windows (`ToUnicodeEx`).
- `OverlayController` связывает окна, хук, опрос платы, трей и настройки; режим разделения создаёт второе окно для правой половины.
## Поддерживаемые кейкоды
| Что | Примеры |
|---|---|
| Буквы, цифры, знаки, F1–F24 | `KC_A`, `KC_1`, `KC_SCOLON`, `KC_F12` |
| Навигация и служебные | `KC_ENTER`, `KC_BSPACE`, `KC_DELETE`, стрелки, `KC_PGUP`, `KC_HOME`, `KC_PSCREEN` |
| Модификаторы | `KC_LSHIFT`, `KC_RCTRL`, `KC_LALT`, `KC_LGUI` |
| Медиа | `KC_MPLY`, `KC_VOLU`, `KC_BRIU` |
| Обёртки с модификаторами | `LSFT(KC_1)`, `LCTL(KC_C)` (а также `S`, `C`, `A`, `G`, правые варианты) |
| Слои | `MO(n)`, `TG(n)`, `TO(n)`, `TT(n)`, `OSL(n)`, `DF(n)`, `LT(n, kc)` |
| Mod-tap | `LCTL_T(kc)`, `MT(MOD_LSFT, kc)` |
| Прозрачная / пустая | `KC_TRNS`, `KC_NO` |
Всё остальное (макросы, tap dance, `QK_BOOT` и т.п.) показывается сокращённым именем кейкода без расшифровки.
Сейчас геометрия рассчитана на **Silakka54** (матрица 10×6, 54 клавиши); для других плат нужна своя геометрия.
## Настройки
Файл `%APPDATA%\KeyOverlay\settings.json` создаётся автоматически и меняется через меню трея.
| Поле | Значение |
|---|---|
| `VilPath` | путь к файлу раскладки `.vil` |
| `Opacity` | прозрачность 0.2–1.0 |
| `Scale` | размер 0.5–2.0 |
| `Left`, `Top` | позиция окна (левой половины в режиме разделения) |
| `RightLeft`, `RightTop` | позиция правой половины в режиме разделения |
| `Layer` | последний выбранный вручную слой |
| `AutoLayer` | определять слой по данным платы |
| `HighlightPressed` | подсвечивать нажатые клавиши |
| `LocalizeKeys` | показывать символы выбранного языка ввода |
| `SplitHalves` | показывать половинки отдельными окнами |
Чтобы сбросить настройки, закрой приложение и удали этот файл.
## Если что-то не работает
- **В подписи «плата не отвечает».** Авто-определение слоя работает только с прошивкой из [`firmware/`](firmware/). Проверь, что прошиты обе половины,
кабель подключён к главной (для файла LEFT — левой), а Vial закрыт. Пока слой можно выбрать вручную (Ctrl+Alt+PgUp/PgDn).
- **Оверлея не видно.** Нажми Ctrl+Alt+K или двойной клик по значку в трее. Если окно осталось за пределами экрана, удали `settings.json`.
- **SmartScreen / антивирус ругается.** Exe не подписан, а приложение использует глобальный перехват клавиш (нужен для подсветки). Исходники открыты, собрать можно самому.
- **Поверх игры оверлея нет.** Окно показывается поверх оконных и borderless-приложений, но не поверх эксклюзивного полноэкранного режима.
- **Буквы не меняются при смене языка.** Проверь пункт «Символы по языку ввода» в меню трея. Язык берётся у окна, в которое ты печатаешь.
## Идеи и планы
- Геометрия других плат (описание в JSON), имена слоёв.
- Подпись exe и автообновление.
- Автоматическое отслеживание изменений файла `.vil`.
## Участие
Issues и pull request приветствуются. Перед отправкой запусти `dotnet test tests/KeyOverlay.Core.Tests`; CI делает то же самое и собирает приложение.
## Приватность
Перехват клавиш (low-level hook) нужен только для подсветки нажатых клавиш и горячих клавиш.
Нажатия нигде не сохраняются и не отправляются, события всегда передаются дальше без изменений.
Приложение ничего не отправляет в сеть. Настройки лежат в `%APPDATA%\KeyOverlay\settings.json`.
## Ограничения
- Только Windows (WPF), x64.
- Определение слоя с платы работает через Raw HID и требует прошивки с командой `0xE0`; половина, подключённая к ПК, должна быть главной.
- Caps Lock на подписях не отражается; мёртвые клавиши показываются знаком акцента.
- Vial GUI и оверлей не стоит держать открытыми одновременно: оба общаются с одним HID-интерфейсом.
## Лицензия
Приложение: [MIT](LICENSE). Дополнение для прошивки (`firmware/keyoverlay_hid.c`) собирается внутрь
QMK/Vial и распространяется под GPL-2.0-or-later. Исходники плат и Vial принадлежат их авторам:
[vial-qmk](https://github.com/vial-kb/vial-qmk), [Squalius-cephalus/silakka54](https://github.com/Squalius-cephalus/silakka54).
---
## English
KeyOverlay is a semi-transparent, click-through Windows overlay that shows the keymap of a programmable
(Vial / QMK) split keyboard such as the Silakka54, always on top, to help you learn your layers.
**Features:** draws the board from a Vial `.vil` layout (all layers, `MO`/`TG`/`LT`/mod-tap, transparent keys
falling through to lower layers); **detects the active layer from the keyboard** via a small QMK/Vial firmware
command (`firmware/`); **shows the characters of the active Windows input language**; can **split the two halves
into separate windows**; highlights pressed keys; tray icon with opacity/size options.
**Install:** download the setup or portable zip from [Releases](../../releases); save your layout in Vial via
*File → Save current layout* and pick that `.vil` on first run. **Build:** .NET 8 SDK, `dotnet test tests/KeyOverlay.Core.Tests`,
`dotnet run --project src/KeyOverlay`. Windows only (x64). Key hook is used only for highlighting and hotkeys; nothing is stored or sent.
License: MIT for the app; the firmware add-on is GPL-2.0-or-later (it is compiled into QMK/Vial).
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+6
View File
@@ -0,0 +1,6 @@
# Build image for the Vial firmware: QMK base container + ARM toolchain + qmk CLI.
FROM ghcr.io/qmk/qmk_base_container:latest
RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc-arm-none-eabi binutils-arm-none-eabi libnewlib-arm-none-eabi \
&& rm -rf /var/lib/apt/lists/* \
&& pip3 install --break-system-packages --root-user-action=ignore -q qmk
+53
View File
@@ -0,0 +1,53 @@
# Прошивка Silakka54 (LED, v1.4led) с командой «какой слой активен»
Это исходники релиза [v1.4led](https://github.com/Squalius-cephalus/silakka54/releases/tag/v1.4led)
(keymap `vial_led`, подсветка слоёв) плюс файл `keyoverlay_hid.c`. Он добавляет HID-команду `0xE0`:
оверлей спрашивает плату, и плата отвечает номером активного слоя. Раскладку и LED команда не затрагивает,
работает и при заблокированном Vial.
## Протокол
Запрос (хост → клавиатура, Raw HID, 32 байта): `[0xE0, 0x01, ...]`
Ответ: `[0xE0, 0x01, highest_layer, layer_state (u32 LE), default_layer_state (u32 LE), ...]`
Номер `0xE0` не пересекается с командами VIA (`0x01..0x1F`), Vial (`0xFE`) и «не обработано» (`0xFF`).
Прошивка только читает состояние. Слой отдаёт та половина, которая подключена к ПК (главная).
## Готовые файлы
Собранные `.uf2` лежат в Releases (в git они не хранятся):
- `silakka54_vial_led_keyoverlay_LEFT.uf2` — в левую половину, собран с `MASTER_LEFT`
- `silakka54_vial_led_keyoverlay_RIGHT.uf2` — в правую половину, собран с `MASTER_RIGHT`
LEFT и RIGHT различаются тем, какая половина считается главной (определяет, в какую USB-розетку
втыкать кабель), как и в оригинальных релизах проекта.
## Прошивка платы (RP2040)
1. В Vial: File → Save current layout. После обновления прошивки раскладка может сброситься.
2. Закрой Vial и оверлей. Отключи кабель между половинами (TRRS) на время прошивки.
3. Зажми BOOT на плате и подключи USB (или дважды быстро нажми reset). Появится диск RPI-RP2.
4. Скопируй на него файл своей половины. Плата перезагрузится сама. Повтори для второй половины.
5. Подключи половины обратно. В Vial загрузи сохранённую раскладку (File → Load saved layout), если слои пропали.
Откат: прошей оригинальные `silakka54_vial_led_LEFT.uf2` / `..._RIGHT.uf2` из
[релиза v1.4led](https://github.com/Squalius-cephalus/silakka54/releases/tag/v1.4led) тем же способом.
## Сборка
Нужны Git, bash и Docker.
```
bash firmware/prepare-source.sh # клонирует vial-qmk и silakka54 (v1.4led) в .firmware-work/ и добавляет команду
docker build -t keyoverlay-fw-build firmware
docker run --rm -v "$PWD/.firmware-work/vial-qmk:/qmk_firmware" -v "$PWD/firmware:/fw" -w /qmk_firmware keyoverlay-fw-build sh /fw/build-in-container.sh
```
(В PowerShell вместо `$PWD/...` используй полные пути: `-v C:\путь\.firmware-work\vial-qmk:/qmk_firmware`.)
Готовые файлы появятся в `.firmware-work/vial-qmk/` как `silakka54_vial_led_keyoverlay_LEFT.uf2` и `..._RIGHT.uf2`.
Первая сборка долгая (десятки минут на смонтированной папке Windows), повторные быстрее.
Образ Docker (`Dockerfile`) добавляет к базовому образу QMK компилятор ARM и утилиту `qmk`.
Файл `keyoverlay_hid.c` распространяется под GPL-2.0-or-later, как и QMK/Vial, в который он собирается.
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
# Runs inside the keyoverlay-fw-build image with vial-qmk mounted at /qmk_firmware.
# Builds the silakka54 vial_led keymap twice: MASTER_LEFT (left half, USB side) and MASTER_RIGHT.
set -e
cd /qmk_firmware
git config --global --add safe.directory '*'
KEYMAP_CFG=keyboards/silakka54led/keymaps/vial_led/config.h
sed -i "/MASTER_/d" "$KEYMAP_CFG" # drop leftovers from an interrupted run
cp "$KEYMAP_CFG" /tmp/config.h.orig
for SIDE in LEFT RIGHT; do
cp /tmp/config.h.orig "$KEYMAP_CFG"
printf '\n#define MASTER_%s\n' "$SIDE" >> "$KEYMAP_CFG"
make silakka54led:vial_led
cp silakka54led_vial_led.uf2 "silakka54_vial_led_keyoverlay_$SIDE.uf2"
done
cp /tmp/config.h.orig "$KEYMAP_CFG"
ls -la silakka54_vial_led_keyoverlay_*.uf2
+31
View File
@@ -0,0 +1,31 @@
// SPDX-License-Identifier: GPL-2.0-or-later
//
// KeyOverlay: lets the host ask which layer is active.
//
// Request (host -> keyboard): [0xE0, 0x01, ...]
// Response (keyboard -> host): [0xE0, 0x01, highest_layer, layer_state (u32 LE), default_layer_state (u32 LE), ...]
//
// 0xE0 is outside the ids used by VIA (0x01..0x1F), Vial (0xFE) and "unhandled" (0xFF).
// Only reads state; it changes nothing on the keyboard and works while Vial is locked.
#include QMK_KEYBOARD_H
#include "raw_hid.h"
#include <string.h>
#define KEYOVERLAY_CMD 0xE0
#define KEYOVERLAY_PROTOCOL 0x01
#define KEYOVERLAY_ID_UNHANDLED 0xFF
#define KEYOVERLAY_MIN_LENGTH 11
void raw_hid_receive_kb(uint8_t *data, uint8_t length) {
if (data[0] != KEYOVERLAY_CMD || length < KEYOVERLAY_MIN_LENGTH) {
data[0] = KEYOVERLAY_ID_UNHANDLED;
return;
}
const uint32_t active = (uint32_t)layer_state;
const uint32_t default_ = (uint32_t)default_layer_state;
data[1] = KEYOVERLAY_PROTOCOL;
data[2] = get_highest_layer(layer_state | default_layer_state);
memcpy(&data[3], &active, sizeof(active));
memcpy(&data[7], &default_, sizeof(default_));
}
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Prepares the firmware source tree: vial-qmk + the Silakka54 LED keymap (v1.4led) + the KeyOverlay HID command.
# Usage: bash firmware/prepare-source.sh [work-dir] (default work-dir: .firmware-work next to this repo)
# Safe to run again: sources are only cloned once, copied files are refreshed.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
WORK="${1:-$HERE/../.firmware-work}"
QMK="$WORK/vial-qmk"
SRC="$WORK/silakka54-src"
DEST="$QMK/keyboards/silakka54led"
mkdir -p "$WORK"
if [ ! -d "$QMK" ]; then
git clone --depth 1 --branch vial https://github.com/vial-kb/vial-qmk.git "$QMK"
git -C "$QMK" submodule update --init --depth 1 --recursive
fi
if [ ! -d "$SRC" ]; then
git clone --depth 1 --branch v1.4led https://github.com/Squalius-cephalus/silakka54.git "$SRC"
fi
# The LED keymap lives in the silakka54 repo, not in vial-qmk: copy it in as its own keyboard folder.
mkdir -p "$DEST"
cp -r "$SRC/firmware/." "$DEST/"
cp "$HERE/keyoverlay_hid.c" "$DEST/keymaps/vial_led/"
RULES="$DEST/keymaps/vial_led/rules.mk"
grep -q "keyoverlay_hid.c" "$RULES" || printf '\nSRC += keyoverlay_hid.c\n' >> "$RULES"
echo "Firmware source ready in: $QMK"
echo "Next: docker build -t keyoverlay-fw-build firmware, then run firmware/build-in-container.sh (see firmware/README.md)"
+59
View File
@@ -0,0 +1,59 @@
; Inno Setup script for KeyOverlay. Build with: ISCC.exe /DAppVersion=1.0.0 KeyOverlay.iss
#ifndef AppVersion
#define AppVersion "1.0.0"
#endif
[Setup]
AppId={{6F0C2E52-3D1B-4C7E-9B5A-4E8D2A71C9F3}
AppName=KeyOverlay
AppVersion={#AppVersion}
AppPublisher=KeyOverlay
DefaultDirName={autopf}\KeyOverlay
DefaultGroupName=KeyOverlay
DisableProgramGroupPage=yes
; Per-user install: no administrator rights needed.
PrivilegesRequired=lowest
OutputDir=..\dist
OutputBaseFilename=KeyOverlay-{#AppVersion}-setup
SetupIconFile=..\src\KeyOverlay\app.ico
UninstallDisplayIcon={app}\KeyOverlay.exe
Compression=lzma2/max
SolidCompression=yes
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
WizardStyle=modern
CloseApplications=yes
AppMutex=Local\KeyOverlay.SingleInstance
[Languages]
Name: "russian"; MessagesFile: "compiler:Languages\Russian.isl"
Name: "english"; MessagesFile: "compiler:Default.isl"
[Types]
Name: "full"; Description: "Полная установка"
Name: "compact"; Description: "Только KeyOverlay"
Name: "custom"; Description: "Выборочная установка"; Flags: iscustom
[Components]
Name: "app"; Description: "KeyOverlay"; Types: full compact custom; Flags: fixed
Name: "firmware"; Description: "Прошивки для Silakka54 (LEFT / RIGHT, с командой слоя)"; Types: full
[Tasks]
Name: "desktopicon"; Description: "Ярлык на рабочем столе"; Flags: unchecked
Name: "autostart"; Description: "Запускать вместе с Windows"; Flags: unchecked
[Files]
Source: "..\dist\app\KeyOverlay.exe"; DestDir: "{app}"; Components: app; Flags: ignoreversion
Source: "..\firmware\silakka54_vial_led_keyoverlay_LEFT.uf2"; DestDir: "{app}\firmware"; Components: firmware; Flags: ignoreversion
Source: "..\firmware\silakka54_vial_led_keyoverlay_RIGHT.uf2"; DestDir: "{app}\firmware"; Components: firmware; Flags: ignoreversion
Source: "..\firmware\README.md"; DestDir: "{app}\firmware"; Components: firmware; Flags: ignoreversion
[Icons]
Name: "{autoprograms}\KeyOverlay"; Filename: "{app}\KeyOverlay.exe"
Name: "{autodesktop}\KeyOverlay"; Filename: "{app}\KeyOverlay.exe"; Tasks: desktopicon
[Registry]
Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "KeyOverlay"; ValueData: """{app}\KeyOverlay.exe"""; Flags: uninsdeletevalue; Tasks: autostart
[Run]
Filename: "{app}\KeyOverlay.exe"; Description: "Запустить KeyOverlay"; Flags: nowait postinstall skipifsilent
+10
View File
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Only used to download the Inno Setup compiler (ISCC.exe) from NuGet; builds nothing. -->
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RestorePackagesPath>$(MSBuildThisFileDirectory)packages</RestorePackagesPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Tools.InnoSetup" Version="6.4.3" />
</ItemGroup>
</Project>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>
+48
View File
@@ -0,0 +1,48 @@
namespace KeyOverlay.Core;
public enum Side
{
Left,
Right,
}
/// <summary>Position of a key inside one half: Row 0..3 are the main rows, Row 4 is the thumb row (cols 3..5).</summary>
public readonly record struct KeyId(Side Side, int Row, int Col);
public readonly record struct KeyPoint(double X, double Y);
/// <summary>Silakka54-style split layout: 4x6 per half plus 3 thumb keys, in key units (1.0 = one keycap pitch).</summary>
public static class BoardGeometry
{
public const int ColumnsPerHalf = 6;
public const double HalfGap = 1.6;
// Vertical stagger by physical column, 0 = outermost (pinky), 5 = innermost.
private static readonly double[] ColumnDrop = { 0.45, 0.45, 0.2, 0.0, 0.2, 0.3 };
// Extra drop for the thumb row, indexed by column (only 3..5 are used).
private static readonly double[] ThumbDrop = { 0, 0, 0, 0.3, 0.4, 0.55 };
public static double Width => ColumnsPerHalf + HalfGap + ColumnsPerHalf;
public static double Height => 4 + ThumbDrop[5] + 1;
/// <summary>Width in key units when drawing both halves (null) or just one of them.</summary>
public static double WidthOf(Side? onlySide) => onlySide is null ? Width : ColumnsPerHalf;
/// <summary>
/// Position of a key. With <paramref name="onlySide"/> set, that half is laid out on its own,
/// starting at x = 0 (used when the halves are shown as separate windows).
/// </summary>
public static KeyPoint Position(KeyId key, Side? onlySide = null)
{
var halfX = key.Side == Side.Left ? key.Col : ColumnsPerHalf - 1 - key.Col;
var x = key.Side == Side.Right && onlySide is null ? ColumnsPerHalf + HalfGap + halfX : halfX;
var y = key.Row < 4
? key.Row + ColumnDrop[key.Col]
: 4 + ThumbDrop[key.Col];
return new KeyPoint(x, y);
}
}
+30
View File
@@ -0,0 +1,30 @@
namespace KeyOverlay.Core;
/// <summary>
/// Replaces the default (US) labels of character keys with what the active OS keyboard language types.
/// The Windows-specific lookup is passed in, so this stays pure and testable.
/// </summary>
public static class KeyLocalizer
{
/// <summary>Resolves (canonical keycode, shift held) to the typed text, or null when unknown.</summary>
public delegate string? CharacterLookup(string keycode, bool shift);
public static KeyDescription Localize(KeyDescription key, CharacterLookup lookup)
{
// Only single-character labels of plain character keys: this skips Ctrl+X wrappers, layer keys, arrows, ...
if (key.BaseKeycode is null || key.Main.Length != 1 || !Keycodes.IsCharacterKey(key.BaseKeycode))
{
return key;
}
var text = lookup(key.BaseKeycode, key.RequiresShift);
if (string.IsNullOrEmpty(text) || char.IsControl(text[0]))
{
return key;
}
// Keep letters uppercase like the default labels; shifted keys already come out as typed.
var main = !key.RequiresShift && char.IsLetter(text[0]) ? text.ToUpperInvariant() : text;
return key with { Main = main };
}
}
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
+237
View File
@@ -0,0 +1,237 @@
using System.Text.RegularExpressions;
namespace KeyOverlay.Core;
/// <summary>How one keycode should look on a keycap.</summary>
public sealed record KeyDescription(
string Main,
string? Sub = null,
string? BaseKeycode = null,
bool RequiresShift = false,
bool IsTransparent = false,
bool IsEmpty = false,
bool IsLayerSwitch = false,
bool FromLowerLayer = false);
/// <summary>Turns Vial/QMK keycode strings (KC_A, LSFT(KC_1), MO(2) ...) into keycap labels.</summary>
public static class Keycodes
{
private const int MaxFallbackLength = 7;
private const string KcPrefix = "KC_";
private static readonly Dictionary<string, (string Canonical, string Label)> Table = BuildTable();
private static readonly Dictionary<string, string> ModNames = new(StringComparer.OrdinalIgnoreCase)
{
["LSFT"] = "Shift", ["RSFT"] = "Shift", ["S"] = "Shift", ["SFT"] = "Shift",
["LCTL"] = "Ctrl", ["RCTL"] = "Ctrl", ["C"] = "Ctrl", ["CTL"] = "Ctrl",
["LALT"] = "Alt", ["RALT"] = "AltGr", ["A"] = "Alt", ["ALT"] = "Alt", ["ALGR"] = "AltGr",
["LGUI"] = "Win", ["RGUI"] = "Win", ["G"] = "Win", ["GUI"] = "Win", ["LCMD"] = "Win", ["LWIN"] = "Win",
};
private static readonly Dictionary<string, string> ShiftedSymbols = new()
{
["1"] = "!", ["2"] = "@", ["3"] = "#", ["4"] = "$", ["5"] = "%",
["6"] = "^", ["7"] = "&", ["8"] = "*", ["9"] = "(", ["0"] = ")",
["`"] = "~", ["-"] = "_", ["="] = "+", ["["] = "{", ["]"] = "}",
["\\"] = "|", [";"] = ":", ["'"] = "\"", [","] = "<", ["."] = ">", ["/"] = "?",
};
private static readonly Regex CallPattern = new(@"^\s*([A-Za-z0-9_]+)\s*\((.*)\)\s*$", RegexOptions.Compiled);
private static readonly HashSet<string> PunctuationKeys = new(StringComparer.Ordinal)
{
"KC_GRAVE", "KC_MINUS", "KC_EQUAL", "KC_LBRACKET", "KC_RBRACKET", "KC_BSLASH",
"KC_SCOLON", "KC_QUOTE", "KC_COMMA", "KC_DOT", "KC_SLASH",
};
/// <summary>True for keys that type a character: letters, digits and punctuation (canonical names).</summary>
public static bool IsCharacterKey(string canonicalKeycode) =>
PunctuationKeys.Contains(canonicalKeycode)
|| (canonicalKeycode.Length == 4
&& canonicalKeycode.StartsWith(KcPrefix, StringComparison.Ordinal)
&& char.IsAsciiLetterOrDigit(canonicalKeycode[3]));
public static string Canonicalize(string keycode) =>
Table.TryGetValue(keycode.Trim(), out var entry) ? entry.Canonical : keycode.Trim();
public static KeyDescription Describe(string keycode)
{
var code = keycode.Trim();
if (code is "KC_TRNS" or "KC_TRANSPARENT" or "_______")
{
return new KeyDescription("", IsTransparent: true);
}
if (code is "KC_NO" or "XXXXXXX" or "")
{
return new KeyDescription("", IsEmpty: true);
}
if (Table.TryGetValue(code, out var entry))
{
return new KeyDescription(entry.Label, BaseKeycode: entry.Canonical);
}
var call = CallPattern.Match(code);
if (call.Success)
{
return DescribeCall(call.Groups[1].Value, SplitArgs(call.Groups[2].Value), code);
}
return Fallback(code);
}
private static KeyDescription DescribeCall(string fn, List<string> args, string original)
{
var name = fn.ToUpperInvariant();
if (name is "MO" or "TG" or "TO" or "TT" or "OSL" or "DF" or "PDF" && args.Count == 1)
{
return new KeyDescription($"{name} {args[0]}", IsLayerSwitch: true);
}
if (name == "LT" && args.Count == 2)
{
var tap = Describe(args[1]);
return tap with { Sub = $"L{args[0]}", IsLayerSwitch = true };
}
if (name == "MT" && args.Count == 2)
{
return ModTap(args[0].Replace("MOD_", "", StringComparison.OrdinalIgnoreCase), args[1]);
}
if (name.EndsWith("_T", StringComparison.Ordinal) && args.Count == 1
&& ModNames.ContainsKey(name[..^2]))
{
return ModTap(name[..^2], args[0]);
}
if (ModNames.TryGetValue(name, out var modName) && args.Count == 1)
{
return Wrapped(modName, args[0]);
}
return Fallback(original);
}
private static KeyDescription ModTap(string mod, string tapKeycode)
{
var tap = Describe(tapKeycode);
var modName = ModNames.TryGetValue(mod, out var n) ? n : mod;
return tap with { Sub = modName };
}
private static KeyDescription Wrapped(string modName, string innerKeycode)
{
var inner = Describe(innerKeycode);
if (modName == "Shift")
{
var shifted = ShiftedSymbols.TryGetValue(inner.Main, out var symbol) ? symbol : null;
var main = shifted ?? (inner.Main.Length == 1 ? inner.Main.ToUpperInvariant() : $"Shift+{inner.Main}");
return inner with { Main = main, RequiresShift = true };
}
return inner with { Main = $"{modName}+{inner.Main}" };
}
private static KeyDescription Fallback(string code)
{
if (!code.StartsWith(KcPrefix, StringComparison.Ordinal))
{
return new KeyDescription(code);
}
var text = code[KcPrefix.Length..];
return new KeyDescription(text.Length > MaxFallbackLength ? text[..MaxFallbackLength] : text);
}
private static List<string> SplitArgs(string args)
{
var result = new List<string>();
var depth = 0;
var start = 0;
for (var i = 0; i < args.Length; i++)
{
switch (args[i])
{
case '(': depth++; break;
case ')': depth--; break;
case ',' when depth == 0:
result.Add(args[start..i].Trim());
start = i + 1;
break;
}
}
result.Add(args[start..].Trim());
return result;
}
private static Dictionary<string, (string, string)> BuildTable()
{
var table = new Dictionary<string, (string, string)>(StringComparer.Ordinal);
void Add(string label, params string[] names)
{
foreach (var name in names)
{
table[name] = (names[0], label);
}
}
for (var c = 'A'; c <= 'Z'; c++)
{
Add(c.ToString(), $"KC_{c}");
}
for (var d = 1; d <= 9; d++)
{
Add(d.ToString(), $"KC_{d}");
}
Add("0", "KC_0");
for (var f = 1; f <= 24; f++)
{
Add($"F{f}", $"KC_F{f}");
}
Add("Esc", "KC_ESCAPE", "KC_ESC");
Add("Tab", "KC_TAB");
Add("Enter", "KC_ENTER", "KC_ENT");
Add("Space", "KC_SPACE", "KC_SPC");
Add("Bksp", "KC_BSPACE", "KC_BSPC");
Add("Del", "KC_DELETE", "KC_DEL");
Add("Ins", "KC_INSERT", "KC_INS");
Add("Home", "KC_HOME");
Add("End", "KC_END");
Add("PgUp", "KC_PGUP");
Add("PgDn", "KC_PGDOWN", "KC_PGDN");
Add("→", "KC_RIGHT", "KC_RGHT");
Add("←", "KC_LEFT");
Add("↑", "KC_UP");
Add("↓", "KC_DOWN");
Add("`", "KC_GRAVE", "KC_GRV");
Add("-", "KC_MINUS", "KC_MINS");
Add("=", "KC_EQUAL", "KC_EQL");
Add("[", "KC_LBRACKET", "KC_LBRC");
Add("]", "KC_RBRACKET", "KC_RBRC");
Add("\\", "KC_BSLASH", "KC_BSLS");
Add(";", "KC_SCOLON", "KC_SCLN");
Add("'", "KC_QUOTE", "KC_QUOT");
Add(",", "KC_COMMA", "KC_COMM");
Add(".", "KC_DOT");
Add("/", "KC_SLASH", "KC_SLSH");
Add("Caps", "KC_CAPSLOCK", "KC_CAPS");
Add("Shift", "KC_LSHIFT", "KC_LSFT");
Add("Shift", "KC_RSHIFT", "KC_RSFT");
Add("Ctrl", "KC_LCTRL", "KC_LCTL");
Add("Ctrl", "KC_RCTRL", "KC_RCTL");
Add("Alt", "KC_LALT");
Add("AltGr", "KC_RALT");
Add("Win", "KC_LGUI", "KC_LWIN");
Add("Win", "KC_RGUI", "KC_RWIN");
Add("Menu", "KC_APPLICATION", "KC_APP");
Add("PrtSc", "KC_PSCREEN", "KC_PSCR");
Add("ScrLk", "KC_SCROLLLOCK", "KC_SLCK");
Add("Pause", "KC_PAUSE", "KC_PAUS");
Add("Prev", "KC_MPRV", "KC_MEDIA_PREV_TRACK");
Add("Play", "KC_MPLY", "KC_MEDIA_PLAY_PAUSE");
Add("Next", "KC_MNXT", "KC_MEDIA_NEXT_TRACK");
Add("Mute", "KC_MUTE", "KC_AUDIO_MUTE");
Add("Vol-", "KC_VOLD", "KC_AUDIO_VOL_DOWN");
Add("Vol+", "KC_VOLU", "KC_AUDIO_VOL_UP");
Add("Br-", "KC_BRID", "KC_BRIGHTNESS_DOWN");
Add("Br+", "KC_BRIU", "KC_BRIGHTNESS_UP");
return table;
}
}
+129
View File
@@ -0,0 +1,129 @@
using System.Text.Json;
namespace KeyOverlay.Core;
/// <summary>A Vial/QMK keymap (.vil file): per layer, which keycode sits on which physical key.</summary>
public sealed class Keymap
{
private const int MatrixRows = 10;
private const int MatrixCols = 6;
private const int RowsPerHalf = 5;
private readonly List<Dictionary<KeyId, string>> _layers;
private Keymap(List<Dictionary<KeyId, string>> layers)
{
_layers = layers;
UsedLayers = Enumerable.Range(0, layers.Count)
.Where(i => i == 0 || HasOwnKey(i))
.ToArray();
}
public int LayerCount => _layers.Count;
/// <summary>Layer indexes worth showing: layer 0 plus every layer that defines at least one real key.</summary>
public IReadOnlyList<int> UsedLayers { get; }
public IEnumerable<KeyId> KeysOf(int layer) => _layers[layer].Keys;
public string? GetRaw(int layer, KeyId key) =>
_layers[layer].TryGetValue(key, out var code) ? code : null;
/// <summary>Describes a key; transparent keys fall through to the layer below, as in QMK.</summary>
public KeyDescription Describe(int layer, KeyId key)
{
var fellThrough = false;
for (var i = layer; i >= 0; i--)
{
var raw = GetRaw(i, key);
if (raw is null)
{
break;
}
var description = Keycodes.Describe(raw);
if (!description.IsTransparent)
{
return fellThrough ? description with { FromLowerLayer = true } : description;
}
fellThrough = true;
}
return new KeyDescription("", IsTransparent: true, FromLowerLayer: fellThrough);
}
public static bool IsLit(KeyDescription key, PressedKeys pressed) =>
key.BaseKeycode is not null
&& pressed.IsDown(key.BaseKeycode)
&& (!key.RequiresShift || pressed.ShiftDown);
public static Keymap LoadFile(string path) => Parse(File.ReadAllText(path));
public static Keymap Parse(string json)
{
JsonDocument document;
try
{
document = JsonDocument.Parse(json);
}
catch (JsonException ex)
{
throw new FormatException("The file is not valid JSON.", ex);
}
using (document)
{
if (document.RootElement.ValueKind != JsonValueKind.Object
|| !document.RootElement.TryGetProperty("layout", out var layout)
|| layout.ValueKind != JsonValueKind.Array
|| layout.GetArrayLength() == 0)
{
throw new FormatException("No \"layout\" array found. Save the layout from Vial via File > Save current layout.");
}
var layers = new List<Dictionary<KeyId, string>>();
foreach (var layer in layout.EnumerateArray())
{
layers.Add(ParseLayer(layer));
}
return new Keymap(layers);
}
}
private static Dictionary<KeyId, string> ParseLayer(JsonElement layer)
{
if (layer.ValueKind != JsonValueKind.Array || layer.GetArrayLength() != MatrixRows)
{
throw new FormatException($"Each layer must have {MatrixRows} matrix rows (5 per half).");
}
var keys = new Dictionary<KeyId, string>();
var matrixRow = 0;
foreach (var row in layer.EnumerateArray())
{
if (row.ValueKind != JsonValueKind.Array || row.GetArrayLength() != MatrixCols)
{
throw new FormatException($"Matrix row {matrixRow} must have {MatrixCols} columns.");
}
var side = matrixRow < RowsPerHalf ? Side.Left : Side.Right;
var halfRow = matrixRow % RowsPerHalf;
var col = 0;
foreach (var cell in row.EnumerateArray())
{
if (cell.ValueKind == JsonValueKind.String)
{
keys[new KeyId(side, halfRow, col)] = cell.GetString() ?? "KC_NO";
}
col++;
}
matrixRow++;
}
return keys;
}
private bool HasOwnKey(int layer) =>
_layers[layer].Values.Any(code =>
{
var d = Keycodes.Describe(code);
return !d.IsTransparent && !d.IsEmpty;
});
}
+41
View File
@@ -0,0 +1,41 @@
namespace KeyOverlay.Core;
public readonly record struct LayerReport(int HighestLayer, uint LayerState, uint DefaultLayerState);
/// <summary>
/// Wire format of the KeyOverlay command added to the keyboard firmware (firmware/keyoverlay_hid.c).
/// Payloads are the 32-byte Raw HID reports, without the leading report id.
/// </summary>
public static class LayerProtocol
{
public const byte Command = 0xE0;
public const byte Version = 0x01;
public const int PayloadLength = 32;
private const int HighestLayerOffset = 2;
private const int LayerStateOffset = 3;
private const int DefaultLayerStateOffset = 7;
private const int MinimumResponseLength = 11;
public static byte[] BuildRequest()
{
var request = new byte[PayloadLength];
request[0] = Command;
request[1] = Version;
return request;
}
public static bool TryParse(ReadOnlySpan<byte> payload, out LayerReport report)
{
report = default;
if (payload.Length < MinimumResponseLength || payload[0] != Command || payload[1] != Version)
{
return false;
}
report = new LayerReport(
payload[HighestLayerOffset],
BitConverter.ToUInt32(payload.Slice(LayerStateOffset, 4)),
BitConverter.ToUInt32(payload.Slice(DefaultLayerStateOffset, 4)));
return true;
}
}
+35
View File
@@ -0,0 +1,35 @@
namespace KeyOverlay.Core;
/// <summary>Tracks which physical keys are currently held, fed from key down/up events.</summary>
public sealed class PressedKeys
{
private readonly HashSet<string> _down = new(StringComparer.Ordinal);
public bool ShiftDown => IsDown("KC_LSHIFT") || IsDown("KC_RSHIFT");
public bool CtrlAndLeftAltDown =>
(IsDown("KC_LCTRL") || IsDown("KC_RCTRL")) && IsDown("KC_LALT");
public bool IsDown(string canonicalKeycode) => _down.Contains(canonicalKeycode);
/// <summary>Applies one key event. Returns the canonical keycode, or null if the scan code is unknown.</summary>
public string? Apply(int scanCode, bool extended, bool isDown)
{
var code = ScanCodes.ToKeycode(scanCode, extended);
if (code is null)
{
return null;
}
if (isDown)
{
_down.Add(code);
}
else
{
_down.Remove(code);
}
return code;
}
public void Clear() => _down.Clear();
}
+74
View File
@@ -0,0 +1,74 @@
namespace KeyOverlay.Core;
/// <summary>
/// Maps Windows set-1 scan codes (what the low-level keyboard hook reports) to canonical keycodes.
/// Scan codes identify the physical key, so the result does not depend on the active OS language layout.
/// </summary>
public static class ScanCodes
{
private const int ExtendedFlag = 0x100;
private static readonly Dictionary<int, string> Map = Build();
private static readonly Dictionary<string, int> Reverse = BuildReverse(Map);
public static string? ToKeycode(int scanCode, bool extended) =>
Map.TryGetValue(scanCode | (extended ? ExtendedFlag : 0), out var code) ? code : null;
/// <summary>Scan code of the main (non-extended) physical key for a canonical keycode, or null.</summary>
public static int? ToScanCode(string canonicalKeycode) =>
Reverse.TryGetValue(canonicalKeycode, out var scan) ? scan : null;
private static Dictionary<string, int> BuildReverse(Dictionary<int, string> map)
{
var reverse = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var (scan, code) in map.Where(e => e.Key < ExtendedFlag).OrderBy(e => e.Key))
{
reverse.TryAdd(code, scan);
}
return reverse;
}
private static Dictionary<int, string> Build()
{
var map = new Dictionary<int, string>
{
[0x01] = "KC_ESCAPE", [0x0C] = "KC_MINUS", [0x0D] = "KC_EQUAL", [0x0E] = "KC_BSPACE",
[0x0F] = "KC_TAB", [0x1A] = "KC_LBRACKET", [0x1B] = "KC_RBRACKET", [0x1C] = "KC_ENTER",
[0x1D] = "KC_LCTRL", [0x27] = "KC_SCOLON", [0x28] = "KC_QUOTE", [0x29] = "KC_GRAVE",
[0x2A] = "KC_LSHIFT", [0x2B] = "KC_BSLASH", [0x33] = "KC_COMMA", [0x34] = "KC_DOT",
[0x35] = "KC_SLASH", [0x36] = "KC_RSHIFT", [0x38] = "KC_LALT", [0x39] = "KC_SPACE",
[0x3A] = "KC_CAPSLOCK", [0x46] = "KC_SCROLLLOCK", [0x57] = "KC_F11", [0x58] = "KC_F12",
[ExtendedFlag | 0x1D] = "KC_RCTRL", [ExtendedFlag | 0x38] = "KC_RALT",
[ExtendedFlag | 0x1C] = "KC_ENTER", [ExtendedFlag | 0x35] = "KC_SLASH",
[ExtendedFlag | 0x37] = "KC_PSCREEN", [ExtendedFlag | 0x47] = "KC_HOME",
[ExtendedFlag | 0x48] = "KC_UP", [ExtendedFlag | 0x49] = "KC_PGUP",
[ExtendedFlag | 0x4B] = "KC_LEFT", [ExtendedFlag | 0x4D] = "KC_RIGHT",
[ExtendedFlag | 0x4F] = "KC_END", [ExtendedFlag | 0x50] = "KC_DOWN",
[ExtendedFlag | 0x51] = "KC_PGDOWN", [ExtendedFlag | 0x52] = "KC_INSERT",
[ExtendedFlag | 0x53] = "KC_DELETE", [ExtendedFlag | 0x5B] = "KC_LGUI",
[ExtendedFlag | 0x5C] = "KC_RGUI", [ExtendedFlag | 0x5D] = "KC_APPLICATION",
};
const string topRow = "1234567890";
for (var i = 0; i < topRow.Length; i++)
{
map[0x02 + i] = $"KC_{topRow[i]}";
}
AddRun(map, 0x10, "QWERTYUIOP");
AddRun(map, 0x1E, "ASDFGHJKL");
AddRun(map, 0x2C, "ZXCVBNM");
for (var f = 0; f < 10; f++)
{
map[0x3B + f] = $"KC_F{f + 1}";
}
return map;
}
private static void AddRun(Dictionary<int, string> map, int firstScan, string letters)
{
for (var i = 0; i < letters.Length; i++)
{
map[firstScan + i] = $"KC_{letters[i]}";
}
}
}
+6
View File
@@ -0,0 +1,6 @@
<Application x:Class="KeyOverlay.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
ShutdownMode="OnExplicitShutdown">
<Application.Resources />
</Application>
+40
View File
@@ -0,0 +1,40 @@
using System;
using System.Threading;
using System.Windows;
namespace KeyOverlay;
public partial class App : Application
{
private Mutex? _singleInstance;
private OverlayController? _controller;
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
_singleInstance = new Mutex(true, @"Local\KeyOverlay.SingleInstance", out var isFirst);
if (!isFirst)
{
Shutdown();
return;
}
DispatcherUnhandledException += (_, args) =>
{
MessageBox.Show(args.Exception.Message, "KeyOverlay", MessageBoxButton.OK, MessageBoxImage.Error);
args.Handled = true;
};
var vilFromArgs = e.Args.Length > 0 ? e.Args[0] : null;
_controller = new OverlayController(AppSettings.Load(), vilFromArgs, Shutdown);
_controller.Start();
}
protected override void OnExit(ExitEventArgs e)
{
_controller?.Dispose();
_singleInstance?.Dispose();
base.OnExit(e);
}
}
+65
View File
@@ -0,0 +1,65 @@
using System;
using System.IO;
using System.Text.Json;
namespace KeyOverlay;
internal sealed record AppSettings
{
public const double MinOpacity = 0.2;
public const double MinScale = 0.5;
public const double MaxScale = 2.0;
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
public string? VilPath { get; init; }
public double Opacity { get; init; } = 0.75;
public double Scale { get; init; } = 1.0;
public double? Left { get; init; }
public double? Top { get; init; }
public int Layer { get; init; }
public bool HighlightPressed { get; init; } = true;
public bool AutoLayer { get; init; } = true;
public bool LocalizeKeys { get; init; } = true;
public bool SplitHalves { get; init; }
public double? RightLeft { get; init; }
public double? RightTop { get; init; }
private static string FilePath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "KeyOverlay", "settings.json");
public static AppSettings Load()
{
try
{
var loaded = File.Exists(FilePath)
? JsonSerializer.Deserialize<AppSettings>(File.ReadAllText(FilePath))
: null;
return (loaded ?? new AppSettings()).Sanitized();
}
catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException)
{
return new AppSettings();
}
}
public void Save()
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!);
File.WriteAllText(FilePath, JsonSerializer.Serialize(this, JsonOptions));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Settings are a convenience; failing to persist them must not break the overlay.
}
}
private AppSettings Sanitized() => this with
{
Opacity = Math.Clamp(Opacity, MinOpacity, 1.0),
Scale = Math.Clamp(Scale, MinScale, MaxScale),
Layer = Math.Max(0, Layer),
};
}
+81
View File
@@ -0,0 +1,81 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using KeyOverlay.Core;
namespace KeyOverlay;
/// <summary>Reads the active Windows keyboard layout and asks Windows what each physical key types in it.</summary>
internal static class InputLanguage
{
private const int BufferSize = 8;
private const byte KeyDownFlag = 0x80;
private const int VkShift = 0x10;
private static readonly Dictionary<(IntPtr Layout, string Keycode, bool Shift), string?> Cache = new();
/// <summary>Layout (HKL) of the window the user is typing into, or zero if there is none.</summary>
public static IntPtr CurrentLayout()
{
var window = NativeMethods.GetForegroundWindow();
if (window == IntPtr.Zero)
{
return IntPtr.Zero;
}
var thread = NativeMethods.GetWindowThreadProcessId(window, out _);
return NativeMethods.GetKeyboardLayout(thread);
}
/// <summary>Two-letter language code of a layout, e.g. "RU" or "EN"; empty if unknown.</summary>
public static string Code(IntPtr layout)
{
var languageId = (int)((long)layout & 0xFFFF);
try
{
return CultureInfo.GetCultureInfo(languageId).TwoLetterISOLanguageName.ToUpperInvariant();
}
catch (CultureNotFoundException)
{
return "";
}
}
/// <summary>The text the physical key of <paramref name="keycode"/> types in <paramref name="layout"/>.</summary>
public static string? CharacterFor(IntPtr layout, string keycode, bool shift)
{
var key = (layout, keycode, shift);
if (!Cache.TryGetValue(key, out var text))
{
text = Lookup(layout, keycode, shift);
Cache[key] = text;
}
return text;
}
private static string? Lookup(IntPtr layout, string keycode, bool shift)
{
var scan = ScanCodes.ToScanCode(keycode);
if (scan is null)
{
return null;
}
var virtualKey = NativeMethods.MapVirtualKeyEx((uint)scan.Value, NativeMethods.MAPVK_VSC_TO_VK_EX, layout);
if (virtualKey == 0)
{
return null;
}
var state = new byte[256];
if (shift)
{
state[VkShift] = KeyDownFlag;
}
var buffer = new StringBuilder(BufferSize);
var count = NativeMethods.ToUnicodeEx(
virtualKey, (uint)scan.Value, state, buffer, BufferSize, NativeMethods.TO_UNICODE_KEEP_STATE, layout);
// A negative count is a dead key (accent); its character is still in the buffer.
return count == 0 ? null : buffer.ToString(0, 1);
}
}
+23
View File
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>
<AssemblyName>KeyOverlay</AssemblyName>
<RootNamespace>KeyOverlay</RootNamespace>
<ApplicationIcon>app.ico</ApplicationIcon>
<Version>1.0.0</Version>
<Product>KeyOverlay</Product>
<Description>Overlay that shows the layers of a Vial/QMK split keyboard</Description>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\KeyOverlay.Core\KeyOverlay.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="HidSharp" Version="2.1.0" />
</ItemGroup>
</Project>
+58
View File
@@ -0,0 +1,58 @@
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.InteropServices;
namespace KeyOverlay;
/// <summary>
/// Global low-level keyboard hook. It only reports scan codes and up/down state to the overlay;
/// nothing is stored, logged or sent anywhere, and events are always passed on unchanged.
/// </summary>
internal sealed class KeyboardHook : IDisposable
{
private readonly NativeMethods.LowLevelKeyboardProc _callback;
private IntPtr _handle;
public KeyboardHook()
{
_callback = OnHook; // keep a reference so the delegate is not garbage collected
using var process = Process.GetCurrentProcess();
var module = process.MainModule?.ModuleName;
_handle = NativeMethods.SetWindowsHookEx(
NativeMethods.WH_KEYBOARD_LL, _callback, NativeMethods.GetModuleHandle(module), 0);
if (_handle == IntPtr.Zero)
{
throw new Win32Exception(Marshal.GetLastWin32Error(), "Could not install the keyboard hook.");
}
}
/// <summary>Raised with (scanCode, isExtended, isDown) on the UI thread.</summary>
public event Action<int, bool, bool>? KeyChanged;
public void Dispose()
{
if (_handle != IntPtr.Zero)
{
NativeMethods.UnhookWindowsHookEx(_handle);
_handle = IntPtr.Zero;
}
}
private IntPtr OnHook(int nCode, IntPtr wParam, IntPtr lParam)
{
if (nCode >= 0)
{
var message = wParam.ToInt32();
var isDown = message is NativeMethods.WM_KEYDOWN or NativeMethods.WM_SYSKEYDOWN;
var isUp = message is NativeMethods.WM_KEYUP or NativeMethods.WM_SYSKEYUP;
if (isDown || isUp)
{
var info = Marshal.PtrToStructure<NativeMethods.KbdLlHookStruct>(lParam);
var extended = (info.Flags & NativeMethods.LLKHF_EXTENDED) != 0;
KeyChanged?.Invoke((int)info.ScanCode, extended, isDown);
}
}
return NativeMethods.CallNextHookEx(_handle, nCode, wParam, lParam);
}
}
+140
View File
@@ -0,0 +1,140 @@
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Media;
using KeyOverlay.Core;
namespace KeyOverlay;
/// <summary>Draws the split keyboard for one layer of the loaded keymap.</summary>
internal sealed class KeyboardView : FrameworkElement
{
private const double BaseUnit = 46;
private const double KeyGap = 4;
private const double CornerRadius = 7;
private static readonly Typeface Face = new("Segoe UI");
private static readonly Brush KeyFill = Freeze(new SolidColorBrush(Color.FromRgb(0x2B, 0x33, 0x3C)));
private static readonly Brush FallthroughFill = Freeze(new SolidColorBrush(Color.FromRgb(0x1D, 0x23, 0x29)));
private static readonly Brush LayerKeyFill = Freeze(new SolidColorBrush(Color.FromRgb(0x4A, 0x3D, 0x1C)));
private static readonly Brush LitFill = Freeze(new SolidColorBrush(Color.FromRgb(0x2E, 0xA8, 0xFF)));
private static readonly Brush TextBrush = Freeze(new SolidColorBrush(Color.FromRgb(0xF1, 0xF4, 0xF7)));
private static readonly Brush DimTextBrush = Freeze(new SolidColorBrush(Color.FromArgb(0x70, 0xF1, 0xF4, 0xF7)));
private static readonly Brush LitTextBrush = Freeze(new SolidColorBrush(Color.FromRgb(0x06, 0x1B, 0x2B)));
private static readonly Brush SubBrush = Freeze(new SolidColorBrush(Color.FromRgb(0xFF, 0xC8, 0x57)));
private static readonly Pen LayerKeyPen = Freeze(new Pen(SubBrush, 1.5));
private static readonly Pen EmptyKeyPen = Freeze(new Pen(new SolidColorBrush(Color.FromArgb(0x40, 0xFF, 0xFF, 0xFF)), 1));
private Keymap? _keymap;
private int _layer;
private PressedKeys? _pressed;
private double _scale = 1.0;
private Side? _onlySide;
private double Unit => BaseUnit * _scale;
/// <summary>Size of one key unit in device-independent pixels at the current scale.</summary>
public double UnitSize => Unit;
/// <summary>Optional hook that rewrites each key's label just before it is drawn (e.g. per input language).</summary>
public Func<KeyDescription, KeyDescription>? Localizer { get; set; }
/// <summary>Draws the whole board (<paramref name="onlySide"/> null) or just one half.</summary>
public void Update(Keymap? keymap, int layer, PressedKeys? pressed, double scale, Side? onlySide)
{
var sizeChanged = !_scale.Equals(scale) || _onlySide != onlySide;
_keymap = keymap;
_layer = layer;
_pressed = pressed;
_scale = scale;
_onlySide = onlySide;
if (sizeChanged)
{
InvalidateMeasure();
}
InvalidateVisual();
}
protected override Size MeasureOverride(Size availableSize) =>
new(BoardGeometry.WidthOf(_onlySide) * Unit, BoardGeometry.Height * Unit);
protected override void OnRender(DrawingContext dc)
{
if (_keymap is null || _layer >= _keymap.LayerCount)
{
return;
}
foreach (var key in _keymap.KeysOf(_layer))
{
if (_onlySide is not null && key.Side != _onlySide)
{
continue;
}
var description = _keymap.Describe(_layer, key);
DrawKey(dc, key, Localizer?.Invoke(description) ?? description);
}
}
private void DrawKey(DrawingContext dc, KeyId id, KeyDescription key)
{
var point = BoardGeometry.Position(id, _onlySide);
var size = Unit - KeyGap;
var rect = new Rect(point.X * Unit + KeyGap / 2, point.Y * Unit + KeyGap / 2, size, size);
var lit = _pressed is not null && Keymap.IsLit(key, _pressed);
var blank = key.IsEmpty || (key.IsTransparent && !key.FromLowerLayer);
if (blank)
{
dc.DrawRoundedRectangle(null, EmptyKeyPen, rect, CornerRadius, CornerRadius);
return;
}
var fill = lit ? LitFill
: key.IsLayerSwitch ? LayerKeyFill
: key.FromLowerLayer ? FallthroughFill
: KeyFill;
var pen = key.IsLayerSwitch && !lit ? LayerKeyPen : null;
dc.DrawRoundedRectangle(fill, pen, rect, CornerRadius, CornerRadius);
var textBrush = lit ? LitTextBrush : key.FromLowerLayer ? DimTextBrush : TextBrush;
DrawLabel(dc, rect, key, textBrush, lit);
}
private void DrawLabel(DrawingContext dc, Rect rect, KeyDescription key, Brush textBrush, bool lit)
{
var hasSub = key.Sub is not null;
var main = MakeText(key.Main, MainFontSize(key.Main), textBrush, rect.Width - 4);
var mainY = rect.Top + (hasSub ? rect.Height * 0.14 : (rect.Height - main.Height) / 2);
dc.DrawText(main, new Point(rect.Left + (rect.Width - main.Width) / 2, mainY));
if (hasSub)
{
var sub = MakeText(key.Sub!, Unit * 0.22, lit ? LitTextBrush : SubBrush, rect.Width - 4);
dc.DrawText(sub, new Point(rect.Left + (rect.Width - sub.Width) / 2, rect.Bottom - sub.Height - rect.Height * 0.08));
}
}
private double MainFontSize(string text) => text.Length switch
{
<= 1 => Unit * 0.42,
<= 3 => Unit * 0.32,
<= 5 => Unit * 0.26,
_ => Unit * 0.21,
};
private FormattedText MakeText(string text, double size, Brush brush, double maxWidth) =>
new(text, CultureInfo.CurrentUICulture, FlowDirection.LeftToRight, Face, size, brush,
VisualTreeHelper.GetDpi(this).PixelsPerDip)
{
MaxTextWidth = maxWidth,
MaxLineCount = 1,
Trimming = TextTrimming.CharacterEllipsis,
};
private static T Freeze<T>(T freezable) where T : Freezable
{
freezable.Freeze();
return freezable;
}
}
+149
View File
@@ -0,0 +1,149 @@
using System;
using System.Linq;
using System.Threading;
using HidSharp;
using KeyOverlay.Core;
namespace KeyOverlay;
/// <summary>
/// Asks the keyboard which layer is active over Raw HID (usage page 0xFF60, usage 0x61).
/// Needs the KeyOverlay firmware command; with stock firmware the keyboard answers "unhandled"
/// and <see cref="Connected"/> stays false. Runs on its own thread; events are raised on that thread.
/// </summary>
internal sealed class LayerPoller : IDisposable
{
private const uint RawHidUsage = 0xFF600061;
private const int IoTimeoutMs = 300;
private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(80);
private static readonly TimeSpan RetryInterval = TimeSpan.FromSeconds(2);
private readonly CancellationTokenSource _stop = new();
private readonly Thread _thread;
private volatile bool _paused;
private volatile bool _connected;
public LayerPoller()
{
_thread = new Thread(Run) { IsBackground = true, Name = "KeyOverlay.LayerPoller" };
}
public event Action<int>? LayerChanged;
public event Action<bool>? ConnectionChanged;
public bool Connected => _connected;
/// <summary>Pauses polling, e.g. while the Vial app is open and talking to the same HID interface.</summary>
public bool Paused
{
get => _paused;
set => _paused = value;
}
public void Start() => _thread.Start();
public void Dispose()
{
_stop.Cancel();
_thread.Join(TimeSpan.FromSeconds(1));
_stop.Dispose();
}
private void Run()
{
var token = _stop.Token;
while (!token.IsCancellationRequested)
{
var device = FindDevice();
if (device is null || !device.TryOpen(out var stream))
{
SetConnected(false);
token.WaitHandle.WaitOne(RetryInterval);
continue;
}
using (stream)
{
PollLoop(device, stream, token);
}
SetConnected(false);
}
}
private void PollLoop(HidDevice device, HidStream stream, CancellationToken token)
{
stream.ReadTimeout = IoTimeoutMs;
stream.WriteTimeout = IoTimeoutMs;
var request = new byte[device.GetMaxOutputReportLength()];
LayerProtocol.BuildRequest().CopyTo(request, 1); // byte 0 is the report id (none used)
var response = new byte[device.GetMaxInputReportLength()];
var lastLayer = -1;
while (!token.IsCancellationRequested)
{
if (!_paused && !TryPollOnce(stream, request, response, ref lastLayer))
{
return; // device unplugged; go back to discovery
}
token.WaitHandle.WaitOne(PollInterval);
}
}
/// <summary>Returns false when the device is gone and the stream should be reopened.</summary>
private bool TryPollOnce(HidStream stream, byte[] request, byte[] response, ref int lastLayer)
{
try
{
stream.Write(request);
var read = stream.Read(response);
if (read > 1 && LayerProtocol.TryParse(response.AsSpan(1, read - 1), out var report))
{
SetConnected(true);
if (report.HighestLayer != lastLayer)
{
lastLayer = report.HighestLayer;
LayerChanged?.Invoke(lastLayer);
}
}
else
{
SetConnected(false);
}
return true;
}
catch (TimeoutException)
{
SetConnected(false);
return true;
}
catch (Exception ex) when (ex is System.IO.IOException or ObjectDisposedException)
{
return false;
}
}
private static HidDevice? FindDevice() =>
DeviceList.Local.GetHidDevices().FirstOrDefault(HasRawHidUsage);
private static bool HasRawHidUsage(HidDevice device)
{
try
{
return device.GetReportDescriptor().DeviceItems
.Any(item => item.Usages.GetAllValues().Contains(RawHidUsage));
}
catch (Exception ex) when (ex is System.IO.IOException or NotSupportedException or UnauthorizedAccessException)
{
return false;
}
}
private void SetConnected(bool connected)
{
if (_connected == connected)
{
return;
}
_connected = connected;
ConnectionChanged?.Invoke(connected);
}
}
+30
View File
@@ -0,0 +1,30 @@
<Window x:Class="KeyOverlay.MainWindow"
x:ClassModifier="internal"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:KeyOverlay"
Title="KeyOverlay"
WindowStyle="None"
AllowsTransparency="True"
Background="Transparent"
Topmost="True"
ShowInTaskbar="False"
ShowActivated="False"
ResizeMode="NoResize"
SizeToContent="WidthAndHeight">
<Border x:Name="Frame"
CornerRadius="12"
Background="#EB11151A"
BorderBrush="Transparent"
BorderThickness="2"
Padding="12,8,12,12">
<StackPanel>
<TextBlock x:Name="Caption"
Margin="2,0,0,6"
FontFamily="Segoe UI"
FontSize="12"
Foreground="#FFB8C4CE" />
<local:KeyboardView x:Name="Board" />
</StackPanel>
</Border>
</Window>
+70
View File
@@ -0,0 +1,70 @@
using System;
using System.Windows;
using System.Windows.Input;
using System.Windows.Interop;
using System.Windows.Media;
using KeyOverlay.Core;
namespace KeyOverlay;
internal partial class MainWindow : Window
{
private static readonly Brush MoveModeBorder = new SolidColorBrush(Color.FromRgb(0x2E, 0xA8, 0xFF));
private bool _interactive;
public MainWindow()
{
InitializeComponent();
SourceInitialized += (_, _) => ApplyExtendedStyles(clickThrough: true);
}
/// <summary>Raised in move mode when the mouse wheel is turned: +1 grow, -1 shrink.</summary>
public event Action<int>? ResizeRequested;
public void SetCaption(string text) => Caption.Text = text;
/// <summary>Which half this window shows; null means the whole keyboard.</summary>
public Side? Half { get; set; }
public void Render(Keymap? keymap, int layer, PressedKeys? pressed, double scale) =>
Board.Update(keymap, layer, pressed, scale, Half);
/// <summary>In move mode the overlay captures the mouse so it can be dragged; otherwise clicks pass through.</summary>
public void SetInteractive(bool interactive)
{
_interactive = interactive;
ApplyExtendedStyles(clickThrough: !interactive);
Frame.BorderBrush = interactive ? MoveModeBorder : Brushes.Transparent;
Cursor = interactive ? Cursors.SizeAll : Cursors.Arrow;
}
protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e)
{
base.OnMouseLeftButtonDown(e);
if (_interactive)
{
DragMove();
}
}
protected override void OnMouseWheel(MouseWheelEventArgs e)
{
base.OnMouseWheel(e);
if (_interactive && e.Delta != 0)
{
ResizeRequested?.Invoke(Math.Sign(e.Delta));
}
}
private void ApplyExtendedStyles(bool clickThrough)
{
var handle = new WindowInteropHelper(this).EnsureHandle();
var style = NativeMethods.GetWindowLong(handle, NativeMethods.GWL_EXSTYLE);
style |= NativeMethods.WS_EX_NOACTIVATE | NativeMethods.WS_EX_TOOLWINDOW;
style = clickThrough
? style | NativeMethods.WS_EX_TRANSPARENT
: style & ~NativeMethods.WS_EX_TRANSPARENT;
NativeMethods.SetWindowLong(handle, NativeMethods.GWL_EXSTYLE, style);
}
}
+75
View File
@@ -0,0 +1,75 @@
using System;
using System.Runtime.InteropServices;
namespace KeyOverlay;
internal static class NativeMethods
{
public const int WH_KEYBOARD_LL = 13;
public const int WM_KEYDOWN = 0x0100;
public const int WM_KEYUP = 0x0101;
public const int WM_SYSKEYDOWN = 0x0104;
public const int WM_SYSKEYUP = 0x0105;
public const uint LLKHF_EXTENDED = 0x01;
public const int GWL_EXSTYLE = -20;
public const int WS_EX_TRANSPARENT = 0x00000020;
public const int WS_EX_TOOLWINDOW = 0x00000080;
public const int WS_EX_NOACTIVATE = 0x08000000;
public delegate IntPtr LowLevelKeyboardProc(int nCode, IntPtr wParam, IntPtr lParam);
[StructLayout(LayoutKind.Sequential)]
public struct KbdLlHookStruct
{
public uint VkCode;
public uint ScanCode;
public uint Flags;
public uint Time;
public UIntPtr ExtraInfo;
}
[DllImport("user32.dll", SetLastError = true)]
public static extern IntPtr SetWindowsHookEx(int idHook, LowLevelKeyboardProc lpfn, IntPtr hMod, uint dwThreadId);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool UnhookWindowsHookEx(IntPtr hhk);
[DllImport("user32.dll")]
public static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
public static extern IntPtr GetModuleHandle(string? lpModuleName);
public const uint MAPVK_VSC_TO_VK_EX = 3;
public const uint TO_UNICODE_KEEP_STATE = 0x4; // Windows 10 1607+: do not change the keyboard state (dead keys)
[DllImport("user32.dll")]
public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")]
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
[DllImport("user32.dll")]
public static extern IntPtr GetKeyboardLayout(uint threadId);
[DllImport("user32.dll")]
public static extern uint MapVirtualKeyEx(uint code, uint mapType, IntPtr keyboardLayout);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
public static extern int ToUnicodeEx(
uint virtualKey,
uint scanCode,
byte[] keyState,
[Out, MarshalAs(UnmanagedType.LPWStr, SizeConst = 8)] System.Text.StringBuilder buffer,
int bufferSize,
uint flags,
IntPtr keyboardLayout);
[DllImport("user32.dll")]
public static extern int GetWindowLong(IntPtr hWnd, int nIndex);
[DllImport("user32.dll")]
public static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
}
+402
View File
@@ -0,0 +1,402 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Windows;
using KeyOverlay.Core;
namespace KeyOverlay;
/// <summary>Owns the overlay window, keyboard hook, tray icon and settings, and wires them together.</summary>
internal sealed class OverlayController : ITrayHost, IDisposable
{
private const double ScaleStep = 0.1;
private const double BottomMargin = 24;
private static readonly TimeSpan LanguagePollInterval = TimeSpan.FromMilliseconds(250);
private readonly string? _vilFromArgs;
private readonly Action _exit;
private readonly PressedKeys _pressed = new();
private readonly MainWindow _window = new();
private MainWindow? _rightWindow;
private KeyboardHook? _hook;
private TrayIcon? _tray;
private LayerPoller? _poller;
private Keymap? _keymap;
private AppSettings _settings;
private int _boardLayer = -1;
private IntPtr _inputLayout;
private System.Windows.Threading.DispatcherTimer? _languageTimer;
public OverlayController(AppSettings settings, string? vilFromArgs, Action exit)
{
_settings = settings;
_vilFromArgs = vilFromArgs;
_exit = exit;
}
public AppSettings Settings => _settings;
public IReadOnlyList<int> Layers => _keymap?.UsedLayers ?? Array.Empty<int>();
public int CurrentLayer { get; private set; }
public bool MoveMode { get; private set; }
public bool OverlayVisible => _window.IsVisible;
public bool BoardConnected => _poller?.Connected ?? false;
public string InputLanguageCode => _inputLayout == IntPtr.Zero ? "" : InputLanguage.Code(_inputLayout);
public void ToggleLocalize()
{
_settings = _settings with { LocalizeKeys = !_settings.LocalizeKeys };
_settings.Save();
Refresh();
}
/// <summary>Rewrites key labels to what the active input language types; no-op when switched off.</summary>
private KeyDescription LocalizeLabel(KeyDescription key) =>
_settings.LocalizeKeys && _inputLayout != IntPtr.Zero
? KeyLocalizer.Localize(key, (code, shift) => InputLanguage.CharacterFor(_inputLayout, code, shift))
: key;
private void PollInputLanguage()
{
var layout = InputLanguage.CurrentLayout();
if (layout != IntPtr.Zero && layout != _inputLayout)
{
_inputLayout = layout;
Refresh();
}
}
public void Start()
{
_window.Opacity = _settings.Opacity;
_window.ResizeRequested += OnResizeRequested;
_window.Board.Localizer = LocalizeLabel;
_window.Show();
_languageTimer = new System.Windows.Threading.DispatcherTimer { Interval = LanguagePollInterval };
_languageTimer.Tick += (_, _) => PollInputLanguage();
_languageTimer.Start();
var path = _vilFromArgs ?? _settings.VilPath;
if ((path is null || !File.Exists(path)) && !PromptForFile(out path))
{
path = null;
}
if (path is not null)
{
LoadKeymap(path);
}
ApplySplit();
Refresh();
// Size is only known after the first layout pass, so place the windows once it is idle.
_window.Dispatcher.BeginInvoke(PlaceWindows, System.Windows.Threading.DispatcherPriority.ApplicationIdle);
_tray = new TrayIcon(this);
_poller = new LayerPoller();
_poller.LayerChanged += layer => _window.Dispatcher.BeginInvoke(() => OnBoardLayer(layer));
_poller.ConnectionChanged += _ => _window.Dispatcher.BeginInvoke(Refresh);
_poller.Start();
try
{
_hook = new KeyboardHook();
_hook.KeyChanged += OnKey;
}
catch (System.ComponentModel.Win32Exception ex)
{
MessageBox.Show($"Не удалось подключить перехват клавиш: {ex.Message}\nПодсветка нажатий и горячие клавиши отключены.",
"KeyOverlay", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
public void Dispose()
{
_languageTimer?.Stop();
_hook?.Dispose();
_poller?.Dispose();
_tray?.Dispose();
SavePosition();
_rightWindow?.Close();
_window.Close();
}
public void ToggleSplit()
{
SavePosition();
_settings = _settings with { SplitHalves = !_settings.SplitHalves };
_settings.Save();
ApplySplit();
Refresh();
_window.Dispatcher.BeginInvoke(PlaceRightWindow, System.Windows.Threading.DispatcherPriority.ApplicationIdle);
}
/// <summary>Creates or closes the second window so it matches the SplitHalves setting.</summary>
private void ApplySplit()
{
if (_settings.SplitHalves)
{
_window.Half = Side.Left;
if (_rightWindow is null)
{
_rightWindow = new MainWindow { Half = Side.Right, Opacity = _settings.Opacity };
_rightWindow.ResizeRequested += OnResizeRequested;
_rightWindow.Board.Localizer = LocalizeLabel;
_rightWindow.SetInteractive(MoveMode);
if (_window.IsVisible)
{
_rightWindow.Show();
}
}
}
else
{
_window.Half = null;
_rightWindow?.Close();
_rightWindow = null;
}
}
public void OpenFile()
{
if (PromptForFile(out var path))
{
LoadKeymap(path);
Refresh();
}
}
public void Reload()
{
if (_settings.VilPath is not null)
{
LoadKeymap(_settings.VilPath);
Refresh();
}
}
/// <summary>Manual layer choice; turns automatic layer detection off.</summary>
public void SelectLayer(int layer)
{
CurrentLayer = layer;
_settings = _settings with { Layer = layer, AutoLayer = false };
_settings.Save();
Refresh();
}
public void ToggleAutoLayer()
{
_settings = _settings with { AutoLayer = !_settings.AutoLayer };
_settings.Save();
if (_settings.AutoLayer)
{
ApplyBoardLayer();
}
Refresh();
}
private void OnBoardLayer(int layer)
{
_boardLayer = layer;
ApplyBoardLayer();
Refresh();
}
private void ApplyBoardLayer()
{
if (_settings.AutoLayer && _keymap is not null && _boardLayer >= 0 && _boardLayer < _keymap.LayerCount)
{
CurrentLayer = _boardLayer;
}
}
public void SetOpacity(double opacity)
{
_settings = _settings with { Opacity = Math.Clamp(opacity, AppSettings.MinOpacity, 1.0) };
_window.Opacity = _settings.Opacity;
if (_rightWindow is not null)
{
_rightWindow.Opacity = _settings.Opacity;
}
_settings.Save();
}
public void SetScale(double scale)
{
_settings = _settings with { Scale = Math.Clamp(scale, AppSettings.MinScale, AppSettings.MaxScale) };
_settings.Save();
Refresh();
}
public void ToggleHighlight()
{
_settings = _settings with { HighlightPressed = !_settings.HighlightPressed };
_settings.Save();
Refresh();
}
public void ToggleMoveMode()
{
MoveMode = !MoveMode;
_window.SetInteractive(MoveMode);
_rightWindow?.SetInteractive(MoveMode);
if (!MoveMode)
{
SavePosition();
}
Refresh();
}
public void ToggleVisible()
{
if (_window.IsVisible)
{
_window.Hide();
_rightWindow?.Hide();
}
else
{
_window.Show();
_rightWindow?.Show();
}
}
public void Exit() => _exit();
private void OnKey(int scanCode, bool extended, bool isDown)
{
var code = _pressed.Apply(scanCode, extended, isDown);
if (code is null)
{
return;
}
if (isDown && _pressed.CtrlAndLeftAltDown)
{
switch (code)
{
case "KC_PGUP": StepLayer(-1); return;
case "KC_PGDOWN": StepLayer(1); return;
case "KC_K": ToggleVisible(); return;
}
}
if (_settings.HighlightPressed && _window.IsVisible)
{
Refresh();
}
}
private void StepLayer(int direction)
{
var layers = Layers;
if (layers.Count == 0)
{
return;
}
var index = Math.Max(0, layers.ToList().IndexOf(CurrentLayer));
SelectLayer(layers[(index + direction + layers.Count) % layers.Count]);
}
private void OnResizeRequested(int direction) =>
SetScale(Math.Round(_settings.Scale + direction * ScaleStep, 2));
private void LoadKeymap(string path)
{
try
{
_keymap = Keymap.LoadFile(path);
_settings = _settings with { VilPath = path };
CurrentLayer = _keymap.UsedLayers.Contains(_settings.Layer) ? _settings.Layer : 0;
_settings.Save();
}
catch (Exception ex) when (ex is IOException or FormatException or UnauthorizedAccessException)
{
MessageBox.Show($"Не удалось прочитать раскладку:\n{path}\n\n{ex.Message}",
"KeyOverlay", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void Refresh()
{
var caption = _keymap is null
? "Раскладка не загружена — правый клик по значку в трее → «Открыть файл раскладки»"
: $"Слой {CurrentLayer}" + LayerSourceHint() + LanguageHint() + (MoveMode ? " · режим перемещения: тащите мышью, колесо — размер" : "");
var pressed = _settings.HighlightPressed ? _pressed : null;
_window.SetCaption(caption);
_window.Render(_keymap, CurrentLayer, pressed, _settings.Scale);
if (_rightWindow is not null)
{
_rightWindow.SetCaption(_keymap is null ? "" : $"Слой {CurrentLayer}");
_rightWindow.Render(_keymap, CurrentLayer, pressed, _settings.Scale);
}
}
private string LanguageHint() =>
_settings.LocalizeKeys && InputLanguageCode.Length > 0 ? $" · {InputLanguageCode}" : "";
private string LayerSourceHint()
{
if (!_settings.AutoLayer)
{
return " · вручную";
}
return BoardConnected ? " · с платы" : " · плата не отвечает";
}
private void PlaceWindows()
{
_window.UpdateLayout();
var area = SystemParameters.WorkArea;
var left = _settings.Left ?? area.Left + (area.Width - _window.ActualWidth) / 2;
var top = _settings.Top ?? area.Bottom - _window.ActualHeight - BottomMargin;
var onScreen = IsOnScreen(left, top);
_window.Left = onScreen ? left : area.Left + 40;
_window.Top = onScreen ? top : area.Top + 40;
PlaceRightWindow();
}
/// <summary>
/// Puts the right half at its saved spot, or where it sat in the combined view
/// (right of the left half, one half-gap apart).
/// </summary>
private void PlaceRightWindow()
{
if (_rightWindow is null)
{
return;
}
_rightWindow.UpdateLayout();
var defaultLeft = _window.Left + (BoardGeometry.ColumnsPerHalf + BoardGeometry.HalfGap) * _window.Board.UnitSize;
var left = _settings.RightLeft ?? defaultLeft;
var top = _settings.RightTop ?? _window.Top;
var onScreen = IsOnScreen(left, top);
_rightWindow.Left = onScreen ? left : defaultLeft;
_rightWindow.Top = onScreen ? top : _window.Top;
}
private static bool IsOnScreen(double left, double top) =>
SystemParameters.VirtualScreenLeft <= left
&& left < SystemParameters.VirtualScreenLeft + SystemParameters.VirtualScreenWidth - 50
&& SystemParameters.VirtualScreenTop <= top
&& top < SystemParameters.VirtualScreenTop + SystemParameters.VirtualScreenHeight - 50;
private void SavePosition()
{
_settings = _settings with
{
Left = _window.Left,
Top = _window.Top,
RightLeft = _rightWindow?.Left ?? _settings.RightLeft,
RightTop = _rightWindow?.Top ?? _settings.RightTop,
};
_settings.Save();
}
private static bool PromptForFile(out string path)
{
var dialog = new Microsoft.Win32.OpenFileDialog
{
Title = "Выберите раскладку Vial (.vil)",
Filter = "Раскладка Vial (*.vil)|*.vil|Все файлы (*.*)|*.*",
};
var ok = dialog.ShowDialog() == true;
path = ok ? dialog.FileName : "";
return ok;
}
}
+164
View File
@@ -0,0 +1,164 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Windows.Forms;
namespace KeyOverlay;
/// <summary>What the tray menu needs from the overlay controller.</summary>
internal interface ITrayHost
{
AppSettings Settings { get; }
IReadOnlyList<int> Layers { get; }
int CurrentLayer { get; }
bool MoveMode { get; }
bool OverlayVisible { get; }
bool BoardConnected { get; }
void ToggleAutoLayer();
void ToggleSplit();
string InputLanguageCode { get; }
void ToggleLocalize();
void OpenFile();
void Reload();
void SelectLayer(int layer);
void SetOpacity(double opacity);
void SetScale(double scale);
void ToggleHighlight();
void ToggleMoveMode();
void ToggleVisible();
void Exit();
}
internal sealed class TrayIcon : IDisposable
{
private static readonly double[] OpacityChoices = { 0.3, 0.45, 0.6, 0.75, 0.9, 1.0 };
private static readonly double[] ScaleChoices = { 0.7, 0.85, 1.0, 1.25, 1.5, 1.75 };
private readonly NotifyIcon _icon;
private readonly ITrayHost _host;
private readonly Icon _glyph;
public TrayIcon(ITrayHost host)
{
_host = host;
_glyph = MakeGlyph();
_icon = new NotifyIcon
{
Icon = _glyph,
Text = "KeyOverlay",
Visible = true,
ContextMenuStrip = new ContextMenuStrip(),
};
_icon.ContextMenuStrip.Opening += (_, _) => BuildMenu(_icon.ContextMenuStrip);
_icon.DoubleClick += (_, _) => _host.ToggleVisible();
}
public void Dispose()
{
_icon.Visible = false;
_icon.Dispose();
_glyph.Dispose();
}
private void BuildMenu(ContextMenuStrip menu)
{
menu.Items.Clear();
menu.Items.Add(Item(_host.OverlayVisible ? "Скрыть оверлей (Ctrl+Alt+K)" : "Показать оверлей (Ctrl+Alt+K)", _host.ToggleVisible));
menu.Items.Add(new ToolStripSeparator());
menu.Items.Add(LayerMenu());
var autoText = _host.BoardConnected ? "Слой по данным платы (авто)" : "Слой по данным платы (плата не отвечает)";
menu.Items.Add(Checked(autoText, _host.Settings.AutoLayer, _host.ToggleAutoLayer));
menu.Items.Add(Item("Открыть файл раскладки (.vil)…", _host.OpenFile));
menu.Items.Add(Item("Перечитать файл", _host.Reload));
menu.Items.Add(new ToolStripSeparator());
menu.Items.Add(ChoiceMenu("Прозрачность", OpenOptions(_host.Settings.Opacity, OpacityChoices, _host.SetOpacity)));
menu.Items.Add(ChoiceMenu("Размер", OpenOptions(_host.Settings.Scale, ScaleChoices, _host.SetScale)));
menu.Items.Add(Checked("Подсвечивать нажатые клавиши", _host.Settings.HighlightPressed, _host.ToggleHighlight));
var language = _host.InputLanguageCode.Length > 0 ? $" (сейчас: {_host.InputLanguageCode})" : "";
menu.Items.Add(Checked($"Символы по языку ввода{language}", _host.Settings.LocalizeKeys, _host.ToggleLocalize));
menu.Items.Add(Checked("Разделить половинки (двигать по отдельности)", _host.Settings.SplitHalves, _host.ToggleSplit));
menu.Items.Add(Checked("Режим перемещения (тащить мышью, колесо — размер)", _host.MoveMode, _host.ToggleMoveMode));
menu.Items.Add(new ToolStripSeparator());
menu.Items.Add(Item("Выход", _host.Exit));
}
private ToolStripMenuItem LayerMenu()
{
var root = new ToolStripMenuItem("Слой (Ctrl+Alt+PgUp / PgDn)");
foreach (var layer in _host.Layers)
{
var index = layer;
root.DropDownItems.Add(new ToolStripMenuItem($"Слой {index}", null, (_, _) => _host.SelectLayer(index))
{
Checked = index == _host.CurrentLayer,
});
}
root.Enabled = _host.Layers.Count > 0;
return root;
}
private static List<(string Text, bool IsOn, Action Apply)> OpenOptions(
double current, double[] choices, Action<double> apply)
{
var options = new List<(string, bool, Action)>();
foreach (var choice in choices)
{
var value = choice;
options.Add(($"{value:P0}", Math.Abs(value - current) < 0.01, () => apply(value)));
}
return options;
}
private static ToolStripMenuItem ChoiceMenu(string title, List<(string Text, bool IsOn, Action Apply)> options)
{
var root = new ToolStripMenuItem(title);
foreach (var (text, isOn, apply) in options)
{
root.DropDownItems.Add(new ToolStripMenuItem(text, null, (_, _) => apply()) { Checked = isOn });
}
return root;
}
private static ToolStripMenuItem Item(string text, Action onClick) =>
new(text, null, (_, _) => onClick());
private static ToolStripMenuItem Checked(string text, bool isOn, Action onClick) =>
new(text, null, (_, _) => onClick()) { Checked = isOn };
private static Icon MakeGlyph()
{
using var bitmap = new Bitmap(32, 32);
using (var g = Graphics.FromImage(bitmap))
{
g.SmoothingMode = SmoothingMode.AntiAlias;
g.Clear(Color.Transparent);
using var fill = new SolidBrush(Color.FromArgb(0x2E, 0xA8, 0xFF));
using var path = RoundedRect(new Rectangle(1, 4, 30, 24), 6);
g.FillPath(fill, path);
using var key = new SolidBrush(Color.FromArgb(0x06, 0x1B, 0x2B));
for (var row = 0; row < 2; row++)
{
for (var col = 0; col < 4; col++)
{
g.FillRectangle(key, 5 + col * 6, 9 + row * 7, 4, 4);
}
}
}
var handle = bitmap.GetHicon();
using var temp = Icon.FromHandle(handle);
return (Icon)temp.Clone();
}
private static GraphicsPath RoundedRect(Rectangle r, int radius)
{
var d = radius * 2;
var path = new GraphicsPath();
path.AddArc(r.X, r.Y, d, d, 180, 90);
path.AddArc(r.Right - d, r.Y, d, d, 270, 90);
path.AddArc(r.Right - d, r.Bottom - d, d, d, 0, 90);
path.AddArc(r.X, r.Bottom - d, d, d, 90, 90);
path.CloseFigure();
return path;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

@@ -0,0 +1,103 @@
using KeyOverlay.Core;
using Xunit;
namespace KeyOverlay.Core.Tests;
public class KeyLocalizerTests
{
// Pretends to be a Russian layout: only a few keys are mapped.
private static string? FakeRussian(string keycode, bool shift) => (keycode, shift) switch
{
("KC_Q", false) => "й",
("KC_Q", true) => "Й",
("KC_1", true) => "!",
("KC_SLASH", true) => ",",
("KC_COMMA", false) => "б",
_ => null,
};
[Fact]
public void Letter_IsShownUppercaseInTheActiveLanguage()
{
var localized = KeyLocalizer.Localize(Keycodes.Describe("KC_Q"), FakeRussian);
Assert.Equal("Й", localized.Main);
}
[Fact]
public void ShiftWrappedKey_UsesShiftedCharacter()
{
var localized = KeyLocalizer.Localize(Keycodes.Describe("LSFT(KC_SLASH)"), FakeRussian);
Assert.Equal(",", localized.Main);
}
[Fact]
public void Punctuation_IsReplacedWithLayoutCharacter()
{
var localized = KeyLocalizer.Localize(Keycodes.Describe("KC_COMMA"), FakeRussian);
Assert.Equal("Б", localized.Main); // a letter on a punctuation key is still shown uppercase
}
[Fact]
public void Localizing_KeepsBaseKeycodeForHighlighting()
{
var localized = KeyLocalizer.Localize(Keycodes.Describe("KC_Q"), FakeRussian);
Assert.Equal("KC_Q", localized.BaseKeycode);
}
[Fact]
public void ModTap_KeepsModifierHintAndLocalizesTapKey()
{
var localized = KeyLocalizer.Localize(Keycodes.Describe("LCTL_T(KC_Q)"), FakeRussian);
Assert.Equal("Й", localized.Main);
Assert.Equal("Ctrl", localized.Sub);
}
[Theory]
[InlineData("KC_ESCAPE")]
[InlineData("KC_SPACE")]
[InlineData("KC_LSHIFT")]
[InlineData("KC_RIGHT")]
[InlineData("MO(2)")]
[InlineData("LCTL(KC_Q)")]
public void NonCharacterKeys_AreLeftUntouched(string code)
{
var original = Keycodes.Describe(code);
Assert.Equal(original, KeyLocalizer.Localize(original, FakeRussian));
}
[Fact]
public void UnknownCharacter_FallsBackToDefaultLabel()
{
var original = Keycodes.Describe("KC_W"); // FakeRussian has no mapping for W
Assert.Equal(original, KeyLocalizer.Localize(original, FakeRussian));
}
[Fact]
public void ControlCharacterResult_FallsBackToDefaultLabel()
{
var original = Keycodes.Describe("KC_Q");
var localized = KeyLocalizer.Localize(original, (_, _) => "\u0011");
Assert.Equal(original, localized);
}
[Theory]
[InlineData("KC_A", true)]
[InlineData("KC_5", true)]
[InlineData("KC_GRAVE", true)]
[InlineData("KC_SCOLON", true)]
[InlineData("KC_F1", false)]
[InlineData("KC_ENTER", false)]
public void IsCharacterKey_CoversLettersDigitsAndPunctuation(string code, bool expected) =>
Assert.Equal(expected, Keycodes.IsCharacterKey(code));
[Theory]
[InlineData("KC_A", 0x1E)]
[InlineData("KC_1", 0x02)]
[InlineData("KC_SLASH", 0x35)]
public void ToScanCode_ReturnsPhysicalKeyCode(string code, int expected) =>
Assert.Equal(expected, ScanCodes.ToScanCode(code));
[Fact]
public void ToScanCode_ReturnsNullForUnknownKeycode() =>
Assert.Null(ScanCodes.ToScanCode("KC_NOPE"));
}
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\KeyOverlay.Core\KeyOverlay.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,102 @@
using KeyOverlay.Core;
using Xunit;
namespace KeyOverlay.Core.Tests;
public class KeycodesTests
{
[Fact]
public void Letter_IsUppercaseWithBaseKeycode()
{
var d = Keycodes.Describe("KC_A");
Assert.Equal("A", d.Main);
Assert.Equal("KC_A", d.BaseKeycode);
}
[Fact]
public void ShortQmkAlias_MatchesLongVialName()
{
Assert.Equal(Keycodes.Describe("KC_BSPACE").Main, Keycodes.Describe("KC_BSPC").Main);
Assert.Equal("KC_BSPACE", Keycodes.Describe("KC_BSPC").BaseKeycode);
}
[Fact]
public void ShiftWrapper_ShowsShiftedSymbol()
{
var d = Keycodes.Describe("LSFT(KC_1)");
Assert.Equal("!", d.Main);
Assert.True(d.RequiresShift);
Assert.Equal("KC_1", d.BaseKeycode);
}
[Theory]
[InlineData("LSFT(KC_MINUS)", "_")]
[InlineData("LSFT(KC_BSLASH)", "|")]
[InlineData("LSFT(KC_GRAVE)", "~")]
[InlineData("LSFT(KC_QUOTE)", "\"")]
public void ShiftWrapper_Symbols(string code, string expected) =>
Assert.Equal(expected, Keycodes.Describe(code).Main);
[Fact]
public void CtrlWrapper_ShowsPrefix()
{
var d = Keycodes.Describe("LCTL(KC_C)");
Assert.Equal("Ctrl+C", d.Main);
Assert.False(d.RequiresShift);
}
[Theory]
[InlineData("MO(2)", "MO 2")]
[InlineData("TG(1)", "TG 1")]
[InlineData("TO(0)", "TO 0")]
public void LayerSwitch_IsFlagged(string code, string main)
{
var d = Keycodes.Describe(code);
Assert.Equal(main, d.Main);
Assert.True(d.IsLayerSwitch);
}
[Fact]
public void LayerTap_ShowsTapKeyAndLayer()
{
var d = Keycodes.Describe("LT(1, KC_SPACE)");
Assert.Equal("Space", d.Main);
Assert.Equal("L1", d.Sub);
Assert.Equal("KC_SPACE", d.BaseKeycode);
}
[Theory]
[InlineData("LCTL_T(KC_A)")]
[InlineData("MT(MOD_LCTL, KC_A)")]
public void ModTap_ShowsTapKeyAndModifier(string code)
{
var d = Keycodes.Describe(code);
Assert.Equal("A", d.Main);
Assert.Equal("Ctrl", d.Sub);
Assert.Equal("KC_A", d.BaseKeycode);
}
[Theory]
[InlineData("KC_TRNS")]
[InlineData("KC_TRANSPARENT")]
public void Transparent_IsFlagged(string code) =>
Assert.True(Keycodes.Describe(code).IsTransparent);
[Fact]
public void No_IsEmpty() => Assert.True(Keycodes.Describe("KC_NO").IsEmpty);
[Fact]
public void UnknownKeycode_FallsBackToTrimmedName()
{
var d = Keycodes.Describe("QK_BOOT");
Assert.Equal("QK_BOOT", d.Main);
Assert.Null(d.BaseKeycode);
}
[Fact]
public void Canonicalize_ResolvesAliases()
{
Assert.Equal("KC_ESCAPE", Keycodes.Canonicalize("KC_ESC"));
Assert.Equal("KC_LSHIFT", Keycodes.Canonicalize("KC_LSFT"));
}
}
+125
View File
@@ -0,0 +1,125 @@
using KeyOverlay.Core;
using Xunit;
namespace KeyOverlay.Core.Tests;
public class KeymapTests
{
// 2 layers, 10 matrix rows x 6 cols. Layer 1 is mostly transparent.
private static string Vil(string layer0Row0, string layer1Row0, string layer0Row5 = "[\"KC_BSPACE\",\"KC_0\",\"KC_9\",\"KC_8\",\"KC_7\",\"KC_6\"]")
{
string Layer(string row0, string row5)
{
var rows = new List<string>();
for (var r = 0; r < 10; r++)
{
rows.Add(r switch
{
0 => row0,
5 => row5,
4 or 9 => "[-1,-1,-1,\"KC_TRNS\",\"KC_TRNS\",\"KC_TRNS\"]",
_ => "[\"KC_TRNS\",\"KC_TRNS\",\"KC_TRNS\",\"KC_TRNS\",\"KC_TRNS\",\"KC_TRNS\"]",
});
}
return "[" + string.Join(",", rows) + "]";
}
var tr = "[\"KC_TRNS\",\"KC_TRNS\",\"KC_TRNS\",\"KC_TRNS\",\"KC_TRNS\",\"KC_TRNS\"]";
return "{\"layout\":[" + Layer(layer0Row0, layer0Row5) + "," + Layer(layer1Row0, tr) + "]}";
}
private static readonly string Row0L0 = "[\"KC_ESCAPE\",\"KC_1\",\"KC_2\",\"KC_3\",\"KC_4\",\"KC_5\"]";
private static readonly string Row0L1 = "[\"KC_GRAVE\",\"KC_F1\",\"KC_TRNS\",\"KC_TRNS\",\"KC_TRNS\",\"MO(2)\"]";
[Fact]
public void Parse_ReadsLayerCount()
{
var km = Keymap.Parse(Vil(Row0L0, Row0L1));
Assert.Equal(2, km.LayerCount);
}
[Fact]
public void Parse_MapsLeftHalfRowsAndCols()
{
var km = Keymap.Parse(Vil(Row0L0, Row0L1));
Assert.Equal("KC_ESCAPE", km.GetRaw(0, new KeyId(Side.Left, 0, 0)));
Assert.Equal("KC_5", km.GetRaw(0, new KeyId(Side.Left, 0, 5)));
}
[Fact]
public void Parse_MapsRightHalfFromMatrixRows5To9()
{
var km = Keymap.Parse(Vil(Row0L0, Row0L1));
Assert.Equal("KC_BSPACE", km.GetRaw(0, new KeyId(Side.Right, 0, 0)));
Assert.Equal("KC_6", km.GetRaw(0, new KeyId(Side.Right, 0, 5)));
}
[Fact]
public void Parse_SkipsMinusOnePositions()
{
var km = Keymap.Parse(Vil(Row0L0, Row0L1));
Assert.Null(km.GetRaw(0, new KeyId(Side.Left, 4, 0)));
Assert.NotNull(km.GetRaw(0, new KeyId(Side.Left, 4, 3)));
}
[Fact]
public void Describe_TransparentFallsThroughToLowerLayer()
{
var km = Keymap.Parse(Vil(Row0L0, Row0L1));
var d = km.Describe(1, new KeyId(Side.Left, 0, 2));
Assert.Equal("2", d.Main);
Assert.True(d.FromLowerLayer);
}
[Fact]
public void Describe_OwnKeyIsNotFromLowerLayer()
{
var km = Keymap.Parse(Vil(Row0L0, Row0L1));
var d = km.Describe(1, new KeyId(Side.Left, 0, 1));
Assert.Equal("F1", d.Main);
Assert.False(d.FromLowerLayer);
}
[Fact]
public void UsedLayers_SkipsLayersWithoutOwnKeys()
{
var allTransparent = "[\"KC_TRNS\",\"KC_TRNS\",\"KC_TRNS\",\"KC_TRNS\",\"KC_TRNS\",\"KC_TRNS\"]";
var km = Keymap.Parse(Vil(Row0L0, allTransparent));
Assert.Equal(new[] { 0 }, km.UsedLayers);
}
[Fact]
public void UsedLayers_IncludesLayersWithOwnKeys()
{
var km = Keymap.Parse(Vil(Row0L0, Row0L1));
Assert.Equal(new[] { 0, 1 }, km.UsedLayers);
}
[Theory]
[InlineData("{}")]
[InlineData("{\"layout\":[]}")]
[InlineData("{\"layout\":[[[\"KC_A\"]]]}")]
[InlineData("not json")]
public void Parse_RejectsInvalidInput(string json) =>
Assert.Throws<FormatException>(() => Keymap.Parse(json));
[Fact]
public void IsLit_RequiresBaseKeyDown()
{
var pressed = new PressedKeys();
var a = Keycodes.Describe("KC_A");
Assert.False(Keymap.IsLit(a, pressed));
pressed.Apply(0x1E, false, true);
Assert.True(Keymap.IsLit(a, pressed));
}
[Fact]
public void IsLit_ShiftWrappedKeyNeedsShift()
{
var pressed = new PressedKeys();
var bang = Keycodes.Describe("LSFT(KC_1)");
pressed.Apply(0x02, false, true);
Assert.False(Keymap.IsLit(bang, pressed));
pressed.Apply(0x2A, false, true);
Assert.True(Keymap.IsLit(bang, pressed));
}
}
@@ -0,0 +1,65 @@
using KeyOverlay.Core;
using Xunit;
namespace KeyOverlay.Core.Tests;
public class LayerProtocolTests
{
[Fact]
public void BuildRequest_Is32BytesWithCommandAndVersion()
{
var request = LayerProtocol.BuildRequest();
Assert.Equal(32, request.Length);
Assert.Equal(0xE0, request[0]);
Assert.Equal(0x01, request[1]);
}
[Fact]
public void TryParse_ReadsLayerAndMasks()
{
var payload = new byte[32];
payload[0] = 0xE0;
payload[1] = 0x01;
payload[2] = 2;
payload[3] = 0b0000_0101; // layer_state = layers 0 and 2
payload[7] = 0b0000_0001; // default_layer_state = layer 0
Assert.True(LayerProtocol.TryParse(payload, out var report));
Assert.Equal(2, report.HighestLayer);
Assert.Equal(5u, report.LayerState);
Assert.Equal(1u, report.DefaultLayerState);
}
[Fact]
public void TryParse_ReadsLittleEndianMask()
{
var payload = new byte[32];
payload[0] = 0xE0;
payload[1] = 0x01;
payload[4] = 0x01; // bit 8 of layer_state
Assert.True(LayerProtocol.TryParse(payload, out var report));
Assert.Equal(0x100u, report.LayerState);
}
[Fact]
public void TryParse_RejectsUnhandledResponse()
{
var payload = new byte[32];
payload[0] = 0xFF; // old firmware: "unhandled"
Assert.False(LayerProtocol.TryParse(payload, out _));
}
[Fact]
public void TryParse_RejectsUnknownProtocolVersion()
{
var payload = new byte[32];
payload[0] = 0xE0;
payload[1] = 0x7F;
Assert.False(LayerProtocol.TryParse(payload, out _));
}
[Fact]
public void TryParse_RejectsShortPayload() =>
Assert.False(LayerProtocol.TryParse(new byte[5], out _));
}
@@ -0,0 +1,129 @@
using KeyOverlay.Core;
using Xunit;
namespace KeyOverlay.Core.Tests;
public class ScanCodesTests
{
[Theory]
[InlineData(0x01, false, "KC_ESCAPE")]
[InlineData(0x1E, false, "KC_A")]
[InlineData(0x02, false, "KC_1")]
[InlineData(0x0B, false, "KC_0")]
[InlineData(0x2A, false, "KC_LSHIFT")]
[InlineData(0x1D, false, "KC_LCTRL")]
[InlineData(0x1D, true, "KC_RCTRL")]
[InlineData(0x38, true, "KC_RALT")]
[InlineData(0x4B, true, "KC_LEFT")]
[InlineData(0x3B, false, "KC_F1")]
[InlineData(0x58, false, "KC_F12")]
[InlineData(0x5B, true, "KC_LGUI")]
public void ToKeycode_MapsKnownScanCodes(int scan, bool ext, string expected) =>
Assert.Equal(expected, ScanCodes.ToKeycode(scan, ext));
[Fact]
public void ToKeycode_ReturnsNullForUnknown() =>
Assert.Null(ScanCodes.ToKeycode(0x7F, false));
}
public class PressedKeysTests
{
[Fact]
public void Apply_TracksDownAndUp()
{
var p = new PressedKeys();
p.Apply(0x1E, false, true);
Assert.True(p.IsDown("KC_A"));
p.Apply(0x1E, false, false);
Assert.False(p.IsDown("KC_A"));
}
[Fact]
public void ShiftDown_TrueForEitherShift()
{
var p = new PressedKeys();
p.Apply(0x36, false, true);
Assert.True(p.ShiftDown);
}
[Fact]
public void CtrlAndLeftAlt_DetectsChord()
{
var p = new PressedKeys();
Assert.False(p.CtrlAndLeftAltDown);
p.Apply(0x1D, false, true);
p.Apply(0x38, false, true);
Assert.True(p.CtrlAndLeftAltDown);
}
}
public class BoardGeometryTests
{
[Fact]
public void RightHalf_IsMirroredSoOuterColumnIsRightmost()
{
var outer = BoardGeometry.Position(new KeyId(Side.Right, 0, 0));
var inner = BoardGeometry.Position(new KeyId(Side.Right, 0, 5));
Assert.True(outer.X > inner.X);
}
[Fact]
public void RightHalf_IsToTheRightOfLeftHalf()
{
var leftInner = BoardGeometry.Position(new KeyId(Side.Left, 0, 5));
var rightInner = BoardGeometry.Position(new KeyId(Side.Right, 0, 5));
Assert.True(rightInner.X > leftInner.X + 1);
}
[Fact]
public void SingleHalf_WidthIsSixColumns() =>
Assert.Equal(6, BoardGeometry.WidthOf(Side.Right));
[Fact]
public void SingleHalf_BothWidthMatchesFullBoard() =>
Assert.Equal(BoardGeometry.Width, BoardGeometry.WidthOf(null));
[Theory]
[InlineData(Side.Left)]
[InlineData(Side.Right)]
public void SingleHalf_KeysStartAtOriginAndFitWidth(Side side)
{
for (var col = 0; col < 6; col++)
{
var p = BoardGeometry.Position(new KeyId(side, 0, col), side);
Assert.InRange(p.X, 0, BoardGeometry.WidthOf(side) - 1);
}
}
[Fact]
public void SingleHalf_RightOuterColumnIsStillRightmost()
{
var outer = BoardGeometry.Position(new KeyId(Side.Right, 0, 0), Side.Right);
var inner = BoardGeometry.Position(new KeyId(Side.Right, 0, 5), Side.Right);
Assert.True(outer.X > inner.X);
}
[Fact]
public void SingleHalf_KeepsSameRowPositionAsFullBoard()
{
var key = new KeyId(Side.Right, 2, 1);
Assert.Equal(BoardGeometry.Position(key).Y, BoardGeometry.Position(key, Side.Right).Y);
}
[Fact]
public void AllKeys_FitInsideBoardBounds()
{
foreach (var side in new[] { Side.Left, Side.Right })
{
for (var row = 0; row < 5; row++)
{
for (var col = 0; col < 6; col++)
{
var p = BoardGeometry.Position(new KeyId(side, row, col));
Assert.InRange(p.X, 0, BoardGeometry.Width - 1);
Assert.InRange(p.Y, 0, BoardGeometry.Height - 1);
}
}
}
}
}