commit 292d61bb4757dfc834847bb1bb6d5c8ffa864356 Author: khadzhibekovpa Date: Tue Oct 20 09:43:17 2026 +0300 feat: initial release of SplitOverlay (KeyOverlay 1.0.0) 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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7c076ca --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +* text=auto +*.sh text eol=lf +*.iss text eol=crlf +*.uf2 binary +*.ico binary +*.png binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d4c76b6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..be30c8c --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +bin/ +obj/ +dist +installer/packages +.firmware-work/ +*.uf2 +*.bak diff --git a/KeyOverlay.sln b/KeyOverlay.sln new file mode 100644 index 0000000..7de63d6 --- /dev/null +++ b/KeyOverlay.sln @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..06e7d52 --- /dev/null +++ b/LICENSE @@ -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). diff --git a/README.md b/README.md new file mode 100644 index 0000000..adde468 --- /dev/null +++ b/README.md @@ -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). diff --git a/docs/screenshot-lang-en.png b/docs/screenshot-lang-en.png new file mode 100644 index 0000000..96feb30 Binary files /dev/null and b/docs/screenshot-lang-en.png differ diff --git a/docs/screenshot-lang-ru.png b/docs/screenshot-lang-ru.png new file mode 100644 index 0000000..82b3ca5 Binary files /dev/null and b/docs/screenshot-lang-ru.png differ diff --git a/docs/screenshot-layer0.png b/docs/screenshot-layer0.png new file mode 100644 index 0000000..1f9d6d6 Binary files /dev/null and b/docs/screenshot-layer0.png differ diff --git a/docs/screenshot-layer1.png b/docs/screenshot-layer1.png new file mode 100644 index 0000000..8a7aa64 Binary files /dev/null and b/docs/screenshot-layer1.png differ diff --git a/docs/screenshot-split-left.png b/docs/screenshot-split-left.png new file mode 100644 index 0000000..087ccf5 Binary files /dev/null and b/docs/screenshot-split-left.png differ diff --git a/docs/screenshot-split-right.png b/docs/screenshot-split-right.png new file mode 100644 index 0000000..5d07edf Binary files /dev/null and b/docs/screenshot-split-right.png differ diff --git a/firmware/Dockerfile b/firmware/Dockerfile new file mode 100644 index 0000000..bb773d0 --- /dev/null +++ b/firmware/Dockerfile @@ -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 diff --git a/firmware/README.md b/firmware/README.md new file mode 100644 index 0000000..a645357 --- /dev/null +++ b/firmware/README.md @@ -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, в который он собирается. diff --git a/firmware/build-in-container.sh b/firmware/build-in-container.sh new file mode 100644 index 0000000..5537507 --- /dev/null +++ b/firmware/build-in-container.sh @@ -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 diff --git a/firmware/keyoverlay_hid.c b/firmware/keyoverlay_hid.c new file mode 100644 index 0000000..ac1e0c8 --- /dev/null +++ b/firmware/keyoverlay_hid.c @@ -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 + +#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_)); +} diff --git a/firmware/prepare-source.sh b/firmware/prepare-source.sh new file mode 100644 index 0000000..6618836 --- /dev/null +++ b/firmware/prepare-source.sh @@ -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)" diff --git a/installer/KeyOverlay.iss b/installer/KeyOverlay.iss new file mode 100644 index 0000000..5f2400c --- /dev/null +++ b/installer/KeyOverlay.iss @@ -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 diff --git a/installer/tools.csproj b/installer/tools.csproj new file mode 100644 index 0000000..936c645 --- /dev/null +++ b/installer/tools.csproj @@ -0,0 +1,10 @@ + + + + net8.0 + $(MSBuildThisFileDirectory)packages + + + + + diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..765346e --- /dev/null +++ b/nuget.config @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/KeyOverlay.Core/BoardGeometry.cs b/src/KeyOverlay.Core/BoardGeometry.cs new file mode 100644 index 0000000..7ac80d1 --- /dev/null +++ b/src/KeyOverlay.Core/BoardGeometry.cs @@ -0,0 +1,48 @@ +namespace KeyOverlay.Core; + +public enum Side +{ + Left, + Right, +} + +/// Position of a key inside one half: Row 0..3 are the main rows, Row 4 is the thumb row (cols 3..5). +public readonly record struct KeyId(Side Side, int Row, int Col); + +public readonly record struct KeyPoint(double X, double Y); + +/// Silakka54-style split layout: 4x6 per half plus 3 thumb keys, in key units (1.0 = one keycap pitch). +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; + + /// Width in key units when drawing both halves (null) or just one of them. + public static double WidthOf(Side? onlySide) => onlySide is null ? Width : ColumnsPerHalf; + + /// + /// Position of a key. With set, that half is laid out on its own, + /// starting at x = 0 (used when the halves are shown as separate windows). + /// + 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); + } +} diff --git a/src/KeyOverlay.Core/KeyLocalizer.cs b/src/KeyOverlay.Core/KeyLocalizer.cs new file mode 100644 index 0000000..a1fd942 --- /dev/null +++ b/src/KeyOverlay.Core/KeyLocalizer.cs @@ -0,0 +1,30 @@ +namespace KeyOverlay.Core; + +/// +/// 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. +/// +public static class KeyLocalizer +{ + /// Resolves (canonical keycode, shift held) to the typed text, or null when unknown. + 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 }; + } +} diff --git a/src/KeyOverlay.Core/KeyOverlay.Core.csproj b/src/KeyOverlay.Core/KeyOverlay.Core.csproj new file mode 100644 index 0000000..bfa77a1 --- /dev/null +++ b/src/KeyOverlay.Core/KeyOverlay.Core.csproj @@ -0,0 +1,7 @@ + + + net8.0 + enable + enable + + diff --git a/src/KeyOverlay.Core/Keycodes.cs b/src/KeyOverlay.Core/Keycodes.cs new file mode 100644 index 0000000..d4407ba --- /dev/null +++ b/src/KeyOverlay.Core/Keycodes.cs @@ -0,0 +1,237 @@ +using System.Text.RegularExpressions; + +namespace KeyOverlay.Core; + +/// How one keycode should look on a keycap. +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); + +/// Turns Vial/QMK keycode strings (KC_A, LSFT(KC_1), MO(2) ...) into keycap labels. +public static class Keycodes +{ + private const int MaxFallbackLength = 7; + private const string KcPrefix = "KC_"; + + private static readonly Dictionary Table = BuildTable(); + + private static readonly Dictionary 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 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 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", + }; + + /// True for keys that type a character: letters, digits and punctuation (canonical names). + 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 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 SplitArgs(string args) + { + var result = new List(); + 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 BuildTable() + { + var table = new Dictionary(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; + } +} diff --git a/src/KeyOverlay.Core/Keymap.cs b/src/KeyOverlay.Core/Keymap.cs new file mode 100644 index 0000000..528d24b --- /dev/null +++ b/src/KeyOverlay.Core/Keymap.cs @@ -0,0 +1,129 @@ +using System.Text.Json; + +namespace KeyOverlay.Core; + +/// A Vial/QMK keymap (.vil file): per layer, which keycode sits on which physical key. +public sealed class Keymap +{ + private const int MatrixRows = 10; + private const int MatrixCols = 6; + private const int RowsPerHalf = 5; + + private readonly List> _layers; + + private Keymap(List> layers) + { + _layers = layers; + UsedLayers = Enumerable.Range(0, layers.Count) + .Where(i => i == 0 || HasOwnKey(i)) + .ToArray(); + } + + public int LayerCount => _layers.Count; + + /// Layer indexes worth showing: layer 0 plus every layer that defines at least one real key. + public IReadOnlyList UsedLayers { get; } + + public IEnumerable KeysOf(int layer) => _layers[layer].Keys; + + public string? GetRaw(int layer, KeyId key) => + _layers[layer].TryGetValue(key, out var code) ? code : null; + + /// Describes a key; transparent keys fall through to the layer below, as in QMK. + 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>(); + foreach (var layer in layout.EnumerateArray()) + { + layers.Add(ParseLayer(layer)); + } + return new Keymap(layers); + } + } + + private static Dictionary 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(); + 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; + }); +} diff --git a/src/KeyOverlay.Core/LayerProtocol.cs b/src/KeyOverlay.Core/LayerProtocol.cs new file mode 100644 index 0000000..2f8d280 --- /dev/null +++ b/src/KeyOverlay.Core/LayerProtocol.cs @@ -0,0 +1,41 @@ +namespace KeyOverlay.Core; + +public readonly record struct LayerReport(int HighestLayer, uint LayerState, uint DefaultLayerState); + +/// +/// 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. +/// +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 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; + } +} diff --git a/src/KeyOverlay.Core/PressedKeys.cs b/src/KeyOverlay.Core/PressedKeys.cs new file mode 100644 index 0000000..a05698e --- /dev/null +++ b/src/KeyOverlay.Core/PressedKeys.cs @@ -0,0 +1,35 @@ +namespace KeyOverlay.Core; + +/// Tracks which physical keys are currently held, fed from key down/up events. +public sealed class PressedKeys +{ + private readonly HashSet _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); + + /// Applies one key event. Returns the canonical keycode, or null if the scan code is unknown. + 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(); +} diff --git a/src/KeyOverlay.Core/ScanCodes.cs b/src/KeyOverlay.Core/ScanCodes.cs new file mode 100644 index 0000000..3728407 --- /dev/null +++ b/src/KeyOverlay.Core/ScanCodes.cs @@ -0,0 +1,74 @@ +namespace KeyOverlay.Core; + +/// +/// 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. +/// +public static class ScanCodes +{ + private const int ExtendedFlag = 0x100; + + private static readonly Dictionary Map = Build(); + private static readonly Dictionary Reverse = BuildReverse(Map); + + public static string? ToKeycode(int scanCode, bool extended) => + Map.TryGetValue(scanCode | (extended ? ExtendedFlag : 0), out var code) ? code : null; + + /// Scan code of the main (non-extended) physical key for a canonical keycode, or null. + public static int? ToScanCode(string canonicalKeycode) => + Reverse.TryGetValue(canonicalKeycode, out var scan) ? scan : null; + + private static Dictionary BuildReverse(Dictionary map) + { + var reverse = new Dictionary(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 Build() + { + var map = new Dictionary + { + [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 map, int firstScan, string letters) + { + for (var i = 0; i < letters.Length; i++) + { + map[firstScan + i] = $"KC_{letters[i]}"; + } + } +} diff --git a/src/KeyOverlay/App.xaml b/src/KeyOverlay/App.xaml new file mode 100644 index 0000000..508b35f --- /dev/null +++ b/src/KeyOverlay/App.xaml @@ -0,0 +1,6 @@ + + + diff --git a/src/KeyOverlay/App.xaml.cs b/src/KeyOverlay/App.xaml.cs new file mode 100644 index 0000000..14ecbd5 --- /dev/null +++ b/src/KeyOverlay/App.xaml.cs @@ -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); + } +} diff --git a/src/KeyOverlay/AppSettings.cs b/src/KeyOverlay/AppSettings.cs new file mode 100644 index 0000000..82288c8 --- /dev/null +++ b/src/KeyOverlay/AppSettings.cs @@ -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(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), + }; +} diff --git a/src/KeyOverlay/InputLanguage.cs b/src/KeyOverlay/InputLanguage.cs new file mode 100644 index 0000000..4d918de --- /dev/null +++ b/src/KeyOverlay/InputLanguage.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using KeyOverlay.Core; + +namespace KeyOverlay; + +/// Reads the active Windows keyboard layout and asks Windows what each physical key types in it. +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(); + + /// Layout (HKL) of the window the user is typing into, or zero if there is none. + 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); + } + + /// Two-letter language code of a layout, e.g. "RU" or "EN"; empty if unknown. + public static string Code(IntPtr layout) + { + var languageId = (int)((long)layout & 0xFFFF); + try + { + return CultureInfo.GetCultureInfo(languageId).TwoLetterISOLanguageName.ToUpperInvariant(); + } + catch (CultureNotFoundException) + { + return ""; + } + } + + /// The text the physical key of types in . + 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); + } +} diff --git a/src/KeyOverlay/KeyOverlay.csproj b/src/KeyOverlay/KeyOverlay.csproj new file mode 100644 index 0000000..1aba9f0 --- /dev/null +++ b/src/KeyOverlay/KeyOverlay.csproj @@ -0,0 +1,23 @@ + + + WinExe + net8.0-windows + enable + disable + true + true + KeyOverlay + KeyOverlay + app.ico + 1.0.0 + KeyOverlay + Overlay that shows the layers of a Vial/QMK split keyboard + en + + + + + + + + diff --git a/src/KeyOverlay/KeyboardHook.cs b/src/KeyOverlay/KeyboardHook.cs new file mode 100644 index 0000000..ccd7902 --- /dev/null +++ b/src/KeyOverlay/KeyboardHook.cs @@ -0,0 +1,58 @@ +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace KeyOverlay; + +/// +/// 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. +/// +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."); + } + } + + /// Raised with (scanCode, isExtended, isDown) on the UI thread. + public event Action? 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(lParam); + var extended = (info.Flags & NativeMethods.LLKHF_EXTENDED) != 0; + KeyChanged?.Invoke((int)info.ScanCode, extended, isDown); + } + } + return NativeMethods.CallNextHookEx(_handle, nCode, wParam, lParam); + } +} diff --git a/src/KeyOverlay/KeyboardView.cs b/src/KeyOverlay/KeyboardView.cs new file mode 100644 index 0000000..a31e526 --- /dev/null +++ b/src/KeyOverlay/KeyboardView.cs @@ -0,0 +1,140 @@ +using System; +using System.Globalization; +using System.Windows; +using System.Windows.Media; +using KeyOverlay.Core; + +namespace KeyOverlay; + +/// Draws the split keyboard for one layer of the loaded keymap. +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; + + /// Size of one key unit in device-independent pixels at the current scale. + public double UnitSize => Unit; + + /// Optional hook that rewrites each key's label just before it is drawn (e.g. per input language). + public Func? Localizer { get; set; } + + /// Draws the whole board ( null) or just one half. + 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 freezable) where T : Freezable + { + freezable.Freeze(); + return freezable; + } +} diff --git a/src/KeyOverlay/LayerPoller.cs b/src/KeyOverlay/LayerPoller.cs new file mode 100644 index 0000000..451022c --- /dev/null +++ b/src/KeyOverlay/LayerPoller.cs @@ -0,0 +1,149 @@ +using System; +using System.Linq; +using System.Threading; +using HidSharp; +using KeyOverlay.Core; + +namespace KeyOverlay; + +/// +/// 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 stays false. Runs on its own thread; events are raised on that thread. +/// +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? LayerChanged; + public event Action? ConnectionChanged; + + public bool Connected => _connected; + + /// Pauses polling, e.g. while the Vial app is open and talking to the same HID interface. + 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); + } + } + + /// Returns false when the device is gone and the stream should be reopened. + 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); + } +} diff --git a/src/KeyOverlay/MainWindow.xaml b/src/KeyOverlay/MainWindow.xaml new file mode 100644 index 0000000..83237bb --- /dev/null +++ b/src/KeyOverlay/MainWindow.xaml @@ -0,0 +1,30 @@ + + + + + + + + diff --git a/src/KeyOverlay/MainWindow.xaml.cs b/src/KeyOverlay/MainWindow.xaml.cs new file mode 100644 index 0000000..89d1dfb --- /dev/null +++ b/src/KeyOverlay/MainWindow.xaml.cs @@ -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); + } + + /// Raised in move mode when the mouse wheel is turned: +1 grow, -1 shrink. + public event Action? ResizeRequested; + + public void SetCaption(string text) => Caption.Text = text; + + /// Which half this window shows; null means the whole keyboard. + public Side? Half { get; set; } + + public void Render(Keymap? keymap, int layer, PressedKeys? pressed, double scale) => + Board.Update(keymap, layer, pressed, scale, Half); + + /// In move mode the overlay captures the mouse so it can be dragged; otherwise clicks pass through. + 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); + } +} diff --git a/src/KeyOverlay/NativeMethods.cs b/src/KeyOverlay/NativeMethods.cs new file mode 100644 index 0000000..ea012fa --- /dev/null +++ b/src/KeyOverlay/NativeMethods.cs @@ -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); +} diff --git a/src/KeyOverlay/OverlayController.cs b/src/KeyOverlay/OverlayController.cs new file mode 100644 index 0000000..22170f8 --- /dev/null +++ b/src/KeyOverlay/OverlayController.cs @@ -0,0 +1,402 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Windows; +using KeyOverlay.Core; + +namespace KeyOverlay; + +/// Owns the overlay window, keyboard hook, tray icon and settings, and wires them together. +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 Layers => _keymap?.UsedLayers ?? Array.Empty(); + 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(); + } + + /// Rewrites key labels to what the active input language types; no-op when switched off. + 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); + } + + /// Creates or closes the second window so it matches the SplitHalves setting. + 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(); + } + } + + /// Manual layer choice; turns automatic layer detection off. + 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(); + } + + /// + /// 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). + /// + 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; + } +} diff --git a/src/KeyOverlay/TrayIcon.cs b/src/KeyOverlay/TrayIcon.cs new file mode 100644 index 0000000..f4b59ff --- /dev/null +++ b/src/KeyOverlay/TrayIcon.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Windows.Forms; + +namespace KeyOverlay; + +/// What the tray menu needs from the overlay controller. +internal interface ITrayHost +{ + AppSettings Settings { get; } + IReadOnlyList 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 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; + } +} diff --git a/src/KeyOverlay/app.ico b/src/KeyOverlay/app.ico new file mode 100644 index 0000000..5a766ae Binary files /dev/null and b/src/KeyOverlay/app.ico differ diff --git a/tests/KeyOverlay.Core.Tests/KeyLocalizerTests.cs b/tests/KeyOverlay.Core.Tests/KeyLocalizerTests.cs new file mode 100644 index 0000000..f00faab --- /dev/null +++ b/tests/KeyOverlay.Core.Tests/KeyLocalizerTests.cs @@ -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")); +} diff --git a/tests/KeyOverlay.Core.Tests/KeyOverlay.Core.Tests.csproj b/tests/KeyOverlay.Core.Tests/KeyOverlay.Core.Tests.csproj new file mode 100644 index 0000000..824c155 --- /dev/null +++ b/tests/KeyOverlay.Core.Tests/KeyOverlay.Core.Tests.csproj @@ -0,0 +1,16 @@ + + + net8.0 + enable + enable + false + + + + + + + + + + diff --git a/tests/KeyOverlay.Core.Tests/KeycodesTests.cs b/tests/KeyOverlay.Core.Tests/KeycodesTests.cs new file mode 100644 index 0000000..1df9e32 --- /dev/null +++ b/tests/KeyOverlay.Core.Tests/KeycodesTests.cs @@ -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")); + } +} diff --git a/tests/KeyOverlay.Core.Tests/KeymapTests.cs b/tests/KeyOverlay.Core.Tests/KeymapTests.cs new file mode 100644 index 0000000..d33de26 --- /dev/null +++ b/tests/KeyOverlay.Core.Tests/KeymapTests.cs @@ -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(); + 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(() => 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)); + } +} diff --git a/tests/KeyOverlay.Core.Tests/LayerProtocolTests.cs b/tests/KeyOverlay.Core.Tests/LayerProtocolTests.cs new file mode 100644 index 0000000..452593e --- /dev/null +++ b/tests/KeyOverlay.Core.Tests/LayerProtocolTests.cs @@ -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 _)); +} diff --git a/tests/KeyOverlay.Core.Tests/ScanCodesTests.cs b/tests/KeyOverlay.Core.Tests/ScanCodesTests.cs new file mode 100644 index 0000000..5deb359 --- /dev/null +++ b/tests/KeyOverlay.Core.Tests/ScanCodesTests.cs @@ -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); + } + } + } + } +}