From 292d61bb4757dfc834847bb1bb6d5c8ffa864356 Mon Sep 17 00:00:00 2001 From: khadzhibekovpa Date: Tue, 20 Oct 2026 09:43:17 +0300 Subject: [PATCH] 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 --- .gitattributes | 6 + .github/workflows/ci.yml | 19 + .gitignore | 7 + KeyOverlay.sln | 43 ++ LICENSE | 24 ++ README.md | 193 +++++++++ docs/screenshot-lang-en.png | Bin 0 -> 17375 bytes docs/screenshot-lang-ru.png | Bin 0 -> 16742 bytes docs/screenshot-layer0.png | Bin 0 -> 43332 bytes docs/screenshot-layer1.png | Bin 0 -> 42089 bytes docs/screenshot-split-left.png | Bin 0 -> 18292 bytes docs/screenshot-split-right.png | Bin 0 -> 17538 bytes firmware/Dockerfile | 6 + firmware/README.md | 53 +++ firmware/build-in-container.sh | 18 + firmware/keyoverlay_hid.c | 31 ++ firmware/prepare-source.sh | 32 ++ installer/KeyOverlay.iss | 59 +++ installer/tools.csproj | 10 + nuget.config | 7 + src/KeyOverlay.Core/BoardGeometry.cs | 48 +++ src/KeyOverlay.Core/KeyLocalizer.cs | 30 ++ src/KeyOverlay.Core/KeyOverlay.Core.csproj | 7 + src/KeyOverlay.Core/Keycodes.cs | 237 +++++++++++ src/KeyOverlay.Core/Keymap.cs | 129 ++++++ src/KeyOverlay.Core/LayerProtocol.cs | 41 ++ src/KeyOverlay.Core/PressedKeys.cs | 35 ++ src/KeyOverlay.Core/ScanCodes.cs | 74 ++++ src/KeyOverlay/App.xaml | 6 + src/KeyOverlay/App.xaml.cs | 40 ++ src/KeyOverlay/AppSettings.cs | 65 +++ src/KeyOverlay/InputLanguage.cs | 81 ++++ src/KeyOverlay/KeyOverlay.csproj | 23 + src/KeyOverlay/KeyboardHook.cs | 58 +++ src/KeyOverlay/KeyboardView.cs | 140 ++++++ src/KeyOverlay/LayerPoller.cs | 149 +++++++ src/KeyOverlay/MainWindow.xaml | 30 ++ src/KeyOverlay/MainWindow.xaml.cs | 70 +++ src/KeyOverlay/NativeMethods.cs | 75 ++++ src/KeyOverlay/OverlayController.cs | 402 ++++++++++++++++++ src/KeyOverlay/TrayIcon.cs | 164 +++++++ src/KeyOverlay/app.ico | Bin 0 -> 3843 bytes .../KeyLocalizerTests.cs | 103 +++++ .../KeyOverlay.Core.Tests.csproj | 16 + tests/KeyOverlay.Core.Tests/KeycodesTests.cs | 102 +++++ tests/KeyOverlay.Core.Tests/KeymapTests.cs | 125 ++++++ .../LayerProtocolTests.cs | 65 +++ tests/KeyOverlay.Core.Tests/ScanCodesTests.cs | 129 ++++++ 48 files changed, 2952 insertions(+) create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 KeyOverlay.sln create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docs/screenshot-lang-en.png create mode 100644 docs/screenshot-lang-ru.png create mode 100644 docs/screenshot-layer0.png create mode 100644 docs/screenshot-layer1.png create mode 100644 docs/screenshot-split-left.png create mode 100644 docs/screenshot-split-right.png create mode 100644 firmware/Dockerfile create mode 100644 firmware/README.md create mode 100644 firmware/build-in-container.sh create mode 100644 firmware/keyoverlay_hid.c create mode 100644 firmware/prepare-source.sh create mode 100644 installer/KeyOverlay.iss create mode 100644 installer/tools.csproj create mode 100644 nuget.config create mode 100644 src/KeyOverlay.Core/BoardGeometry.cs create mode 100644 src/KeyOverlay.Core/KeyLocalizer.cs create mode 100644 src/KeyOverlay.Core/KeyOverlay.Core.csproj create mode 100644 src/KeyOverlay.Core/Keycodes.cs create mode 100644 src/KeyOverlay.Core/Keymap.cs create mode 100644 src/KeyOverlay.Core/LayerProtocol.cs create mode 100644 src/KeyOverlay.Core/PressedKeys.cs create mode 100644 src/KeyOverlay.Core/ScanCodes.cs create mode 100644 src/KeyOverlay/App.xaml create mode 100644 src/KeyOverlay/App.xaml.cs create mode 100644 src/KeyOverlay/AppSettings.cs create mode 100644 src/KeyOverlay/InputLanguage.cs create mode 100644 src/KeyOverlay/KeyOverlay.csproj create mode 100644 src/KeyOverlay/KeyboardHook.cs create mode 100644 src/KeyOverlay/KeyboardView.cs create mode 100644 src/KeyOverlay/LayerPoller.cs create mode 100644 src/KeyOverlay/MainWindow.xaml create mode 100644 src/KeyOverlay/MainWindow.xaml.cs create mode 100644 src/KeyOverlay/NativeMethods.cs create mode 100644 src/KeyOverlay/OverlayController.cs create mode 100644 src/KeyOverlay/TrayIcon.cs create mode 100644 src/KeyOverlay/app.ico create mode 100644 tests/KeyOverlay.Core.Tests/KeyLocalizerTests.cs create mode 100644 tests/KeyOverlay.Core.Tests/KeyOverlay.Core.Tests.csproj create mode 100644 tests/KeyOverlay.Core.Tests/KeycodesTests.cs create mode 100644 tests/KeyOverlay.Core.Tests/KeymapTests.cs create mode 100644 tests/KeyOverlay.Core.Tests/LayerProtocolTests.cs create mode 100644 tests/KeyOverlay.Core.Tests/ScanCodesTests.cs 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 0000000000000000000000000000000000000000..96feb3069c3d0f78588dda65f5e5c3d5f73b4a0a GIT binary patch literal 17375 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

&3!w4{`7 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 literal 0 HcmV?d00001 diff --git a/docs/screenshot-layer0.png b/docs/screenshot-layer0.png new file mode 100644 index 0000000000000000000000000000000000000000..1f9d6d6c900bfec4e46c0e1eab8d6bd674e378fe GIT binary patch literal 43332 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 literal 0 HcmV?d00001 diff --git a/docs/screenshot-split-left.png b/docs/screenshot-split-left.png new file mode 100644 index 0000000000000000000000000000000000000000..087ccf5b86622b7f440ebad3e4d845264f6e35af GIT binary patch literal 18292 zcmbSzcRbbM`?r~utYc>!^VljRWE|(nv5%Q8Ba}Tt5popA-m)Fa$jFvWw#q6avW2Wj z_IPf6zrW{sUeBM;ALr$DKIh(_>%Q;neZAk8a2>6Cl;kYrczAe}C{=_m9v(gxd?HA% zfG3k&-)g`gd{5nbaJ;fF*Vn)oB6}rGB|N-}7>d&e#Na!bo2s!V9v%blTSifw&u>gs8DX@h{u2p9}NQMtNVP)_10vSapq0 zChdR4urjKULOUPp_~czn`TO)*BxQJsxw;ZHT$NF$rVV9K)ECMF&0t%?Y3-)^g3z@Cx zw7@t=IK~2$(3eXE%~d{GRH)Aa4N`kKOd;dk2%wnQ=ksQedodTZKm2}o;!Q1Ac;{aH z+`2nUS!dqlA=2<#zWb|KvHhk&~o0~ zi_$j^OKe1hLR|jeB$?i@COXKFYTZLG{Ifk;RW2A%*~~rrjnwd8x!&l+QhhY@D!EFaN|Ww5z1gX!H!njuQv!mfe4`Ta zfFh-@b(8S;8P@Vvtz9@*(MU}V!SW7h?M(AHExruu{Or4rI+}V!xIK((h8#s3x_Qfv zIE9DY{B{3eqAjQp?}>W-oYQ!xr*2@IvUj_4Up7CJPT}^N?5AT5?2FikYZzF(U|`hg zv)%jVeZ7Mk9fs|}H|^92#&z$#;UCCMd5eH5YoG!{aGagmCXB@I4M-h3g(!@RrUWYv zD^R$hm3gM4*Qg0Wo}=5vIw>%(JEQ#x2sFBr_}bDzj_IQv8>iVkInfq#T7}ttKTPaX zH!duWs@93PSIvAYhFfUImzbqIb;+LZ!8_pr!+y>90yz|f201eOK45(3k5g;l=|Sw& z8_Z(Qclj7qk3NB4@mEpdU6*wIf>gW_Pi(0Uuv&u8*f(`Y@k{r zW<T{^tCQR^_8T%K{O+0RtsgQL1ci@S`8LPLA~L-;P} zNjDfxYW=hdjXLT6`f1F85EG|!1zwp0BNga-87^ReKcF^gFQw%gt#Vs~$qvdx2~3Gr zk+zcd8ZjnMMWrX|CXF>PnS4&t+o5Ceu{jFJX2 zNxaH*+c&I&z1a9=ZXPnw6oH1uXC``=-bj}Thr~E)GNh6!zPJ|Luoikt{*Lp1pJ<6+ z!LJA~o_u*#wg$xx){C4|==)H6J-M+8oCiYeNW8RZlf!7eAj-_lDt0%s>o|^51<5L7 zfC!$2WS;EGy0DrN_Jm4MAeC4Mscs13G)+$_#x~?DR?Z+!aXF%oH~@md{4x1Pg;aV(JpYDTkd91H z$(R9UiL7|;WkzO@wJytAU_aSV2qyQnd*ZO0lQ*~Bv`|z$HK}tVu`0J1tBgX#<(mIvTzkjJ6CMB zgj-AeX29{S6LUr=bNlF3Rdeoj`@Z%RARh}@1gBs+PsShIM zyn&_J%%4r9v_xgwNC9Ti8o+4YEN_3^lHU^9=iP}srGz6kW+w)Q@OUW-Jh5e%Y%=W>v4@DH0)zzWo;@>J^Bff`+;g1aFT0Jb; zCJ@0jf=d;-3E#A7o(@QBV||QadU8b*Nva{rG?|%z6hdeM! zL**!&BHO#+v0Z;?XF?21Np&BnD5BtJSK$Z6v9_vA;+>#@G>38thW*B9r)jHVCgtM z^q25kp1m~JGU9Rlx07mU{d)zy-NwEjnAlE^^4*;>=MGYqi2`VmFuMCci3O@EBMo-o zko(+tqw)g;Cq(QpeaPeXC>ZwFsmHHRZdZwi2)C1s4D&viUNH5H z6C;CdBVB&9GV#C$?$a0GbUdfDU3ME5B11|@p%J|y>C5or^2KO5SKE-MuO;}wa9e0Kl+ zTf6uct4PF;X9uh&@8D>RDb?g^B5a6+lY)uX&y*n+(R(ymzh8ptO`|2ADK0l_rV(j* z{!UPx9_!c2ayQ_dOXleY#@*likO%u2ShJXgNO7fQlYiHQB|Qfs_(R$6bX;UP!z|Ou z`U@N^h7MirM<<2MB8RYj%%z~Q)ubB2z;u-4Eq(87Oq?+_29}>U?>?7o@GUyW(jCIc zZ57Vk=i@6{1?9sQI*Ud>&c4PKYS|-bteN=8rj*|*O{iqi38s!HC2NmjV72}=p3+=? z2U#&t#_F3}3jQVg4;GqAWuLIF7{_3msTc^SW|?l4tEJ#k2Qt}t9HjDXIl??q^&2qF3Uy#X6#`_7y5sTAm( zT|}2hz*cfaxu7$aAXh?zKl=8sH_J*F5y@AM`JT&qevvNFG7BeQn>gV3{H`TsAkZ>G zEBo#}^jE|^#BkI(S=6S|y6y$0yH-(c3OAI?uYc42xq|D)<+R+QHqV_+AP!17+jjF^ zLVveKvGBThCt;Ctrj@Ts{0Jxoy-XRq1f$imn6cJSE$m$SqTuNeb#3g)uOOVm{`og3 zf26#E?-GAa=>SfV3-155`3jwUzbE+hH#H+wnvg$r*MY)yw5zo*o|t)2D>thS-SnM4U(ed-fF?H54S9Ti0$nE@TgvC49VVD z$HBz!%JpNq3YPxU&1aI&lA?$T=bo(xFX zreWD$zbgs$#k|RB<1sC^L;3sDA^#jr^i@8rsScHiUS7O&ez^71TGO>3i^=Ya-m|TT zly+m<*YCDb=ORy8L#RRhH_qtQ@N0`X88Iu_$XPaNr|XuCkkD6PIl>k`afEz@XWGy5 zIpQ3Ob=^aq9|qTM&x*dx%91lxO@y`y_eN;H_?@wE?bCyU z8rJTXwgfk!t2kKv#KBqa>SW+rJ5xa5-0zs~8wTKQbiRRs^Yqrn zDzQ0jS5>G|72DeTn;JW0+sS#^k1UL2G$HpD*iM%3K&=~nlvod3Gg`t3tY$>-l3CL^ z&NMT0)ty{p+L5RH*%{YU7rOc2@vhQT;|0mLJ7GT`pFRV{mGY1Hd4_6%=E_zML9Lfwo$0E#{dG3_EYWa68-<4e#JJ#^KS8 z&Woys?mDX)TM|k5T{Lv?qgU7N#(v{JFbKuvz#w9bmhTusEqY(#3RC28I?zHz{`yZQ z71aMz7Cq&GdCH~(xr5AI3{1=ST)JWR(MS{l1?IpT18X}Tc2BZkQR^2L?5s{AU##}K zs0|gvDRK~7?En5Gb%QAe_g?>)#cRu&Ceimz%%6po)xkJxMbdlEE!bh$b1Xc>TjuS< zQww|I0|AxffyZ^jKhLHZoUse(&9m>G*M@MtTT2g^knerkaX*faAs+|3i3ldPa{Kfl z5xF`X9~)mtfK);|4Bd&`GZuardeXs5r~JMnVO;TqfBhv3eJAmL*aLkTW+eTeOH&v;R}#LCDVVEHw$-)y}$%J-5hT&>~+ zk1{b@={QAod^CBl(zssl(8nsQ0du+o$^nLjkU8v`^rL!yUCXa#xQB*ex59DZBq9<+ zDbUuGA?6y9<*6W;*+}Qv)E%_LLgqJ|gH?3LtO1g3ww-&W!98az$QHPZ{I@?a2Tqn&d|c;Cj}a9N0&F9DLrI((g)tdd;IC zMpeuD9vG4NYhkKZ{!adZl$7C$a0f5{yyG+T7hfDOF;4Tzayi${9Sk_VC&v`HG$+GQYr;i3@$Zx4YV^w;XRvZvs>Ou)Ry?Ov z)hso0Wwb3_B+Ne)ci4M>&NZjDhIyoId*j4PmU7F$6k8Y-7e}cSlhE!Gj!m*Mt?H$dKO#u$z$~{1F^M{V@Tcy8Wu+=wL8F@$l9_Nt9 zinMU%wZ{$dHLp@QjBA1CAqYqBMU?5ote*Zclr(4%w&4i2g>QfTVp8*-9D+o`u+M3p z+HolfX@h$$Clmtb(4h4*6e%Gd}L?P|-UtABa-JG@%tZ5{@h8WJ&;i_vST|-{(#r9cLg$d8*^Scon!Bc7j}`>K^@P*Y72YiTfoL4f z+xSixbiNd-@G8wFiY@IW;;L zL+hf(EzcJzo&`oDoAN4E1Z+mfV~dWOhF_Da+``Gw7p-y>j9B zu)5Mu0d!rQOSV~bTz1MjzGFL;m~Areq{m*#MtaN^QW;o+xzE=XFx#aR5)Vdar2YgI zWh$o<5RBi?=cXn881LRR*UJ0&Hcw zT!eHF*s854M>*jX*Nxa&W&8y7W=(0N^n0s2kGcGEVXAiG3nnx5x!eP!$2?fkzuFF! z9Ys+7%pgA5O#uK)ELT1XP;^`ahp#qnbz!5&5!aSl7~7#K)sKn(qlk{}*MWS?g{ARp zLULbb@YMVK+3SLU;6hR^tXLT>5u*t)r>K$~E(UPa4xk0#1K7|ZQfTle*C)VIrCc%m`!b)WEko`5cFqw-P%kEf*zu3et^T#pUcZ%We>7ySO6 z!lma62K!*O$j=E!T6(P?r-y5UOhdKIuTsayFQd4Zm>jaEov=6=2=b2(WZ9hyE3bJg zn&ivDdlMD_BfM`Hhb@d{1{x55IQ(f|?cC#H)B3Y#kw0%a>6?SyUuCbQg6ycXG&+9m zdqUd^h=FaRyzM|~>y1G|6_Jk@b z0KD}y`mQOTf$kO?el27~`RuiQF_C+g+cWmp^}zWl2aXqt(r{^2?<6~-Lj8vKu`ySS zEh{6_1aWu=9P;d>bA7jLI3_Bdx$e0*8dp^B%0E4Of^x26do?KY=fuw@&;o|tN9OKx zoV!mn9G*=hZ2}Eo*j;d3l%2sGBzteZ_{;X_j-%70g#pt2e8JA(&ID1AHxqT4qB=p( z+yq^LmTAmK*0tLwEw}eqy57K?)+*)zxbOBe*g-+?pvvDGoD@?bNIw%sq^bbU?|rE4 zP#`phl5IW25Dj_OB1_$DYEnTyw{=Dm(!cm~b9Lub@9#z3_Vlgj(K9+yg}wl!n*df~ zOCjy{mv>lg+(d)Tf%vJ{{D@`udGY;iODtEyK9a`miVkfp30R5ul+q=F|e9mmV*GD5khDev2jiQrHo* z!72u3n+q?vblhu&%ViL`ri8k9G9rcrgY0sr3BL*y?JBoVls_(dz2gyjCP)QA-a07P zgrwJP=>*WOT6S#vEdEsJ{@^{73@bBhet@BLZ^doYr!2RuP>4kh0yk?O>MZY2=BNvi z(KIAiY|XqI7HV$M#L`)@$TsH7+ErV!!{nC(OZyP$yjCH!O~N+ei(fDAGay|q`kfX@ zdn{ke>-3fE|Fp6rVeI9yz9}QJzwk!rqqEP}52L9Cf%i{M&0u!Gc0L4-UYTwNAagTi z1yn<`gDdyH{VV$DKgM76GMelkn$+A;@@`0?VN^4|gfz4HLv$*G+|yy34wPFnRkNUP zU=!e?cl@K|sX2-pa;!ALGa;>n4)15~ZwG6qG zz&^n({M~=ZY_uBY^{Sd7=o&w-9ej;uBG59Om0X4Vjj$BD#OT1au5zD`FM&V6Y2myk zoZ!@dx31mH0&L2J)=bI$as2;`8-fhL;mLH-6AaDg=U33nsFSZEe*az9ZNuC|s3zpF zd3gt11DfgefmMj$3OboCW`VaJ|MmcwuRD}nKR)#feDE<5by<5`wduulRBeE{$lb3m zOLdF%ARi?rlayvYY_^XW>&BA78KnNT`GZB(QOut1z%>~b^c>yhYU zbv#U|By6AlijuDHPhv2bq#W+>>A}qE-zlG8^$pnuV3lVg2^gpcc_Gq;o zFlu~R*y)ISZ-$_%;Y8m7D@7E=0REe|GfsKr;1UhYu*;wF?{${95!?LE`l z0tQe(hf`EgX?m0MhI+ekXv)py^z`t_QQhv)OW2+XB?yf^+Ni2{wtYTt``G(UlrH2+ zH-AC%W}PC^;bhb4tKY>j@EEhS!nnwK3F%9+#w{qIEt+|zOm-f1=?ug&c*8fM))U4v zL$@Yco-V$vD8E|U{}OjdIx|6YaqrDiMg^IqLQ+$=+*j|5W1<#1W35X(VT?Zgd*J=| z<8_6jcgH@uLfqHjMzsNs5+c~e)>Ck;4X;&sOP4vKy_~68V(m6qZRpHd#BK)RVA=%; z=!rl);=%f-?hmztnjtvF0+%{Oa0#bzcgt{Ta42EP9d*AiZNC5$=eEhYl?r(8|K;Vcw`?sYI7s)S?$}`(#|-%mWQW__?c~${sBa5)w_*k z^i#qgf^fZjzUWm3WYZh!?0sb{!BpiJG4RgoPY)y|=dM8uZ4|O4_SF{%w|<-Vp4p-3 zx|sT9S%50G&0{d zaQ-BI1#=Y{$4Q=JO2ndnT=|BEO04fFuOE{k;T%S=T2zhRuRe+iV$XSX$6GuuXWBAZ z0v$wsPq63pv;=FZ7`linz59S6saRM&v2^RC-uZVUr-a}DeSuJd+#?t{m7_W&6{4dp z6n!T{oGk0p3z=tI9c~+l^aO%OD&5^70kY{?Ka!2*$xNuBcK({KGsVZ2;%8yP>MYP! zyqr+}0q!-Er?<0|{)4PQV|XHDA_SBsD33Jvpk<}yBZEG@xR-{01RCve1y1h2pKLu7 z>Ug=CRTKO^YLI&{0mju;X19rkuvNYEI| zRW4uBfOrdfor?b5zx2~n=7;9#4w`si3@un384T+ZC8F5nkUoZs!JJQVttloKfeQ+& z139x694ZlIWwM73Oc+sDl1mc3$)8p1MJc28F}PJmb5iY26l=xwJ)Ow~S+X@B6?NQN z@`H8U?;^sIYfiXT6Fy?ad%42)v$3p0T87iQKGJ5MTf z1?UKoLPV_})Z)BP>ex<*omwTJ}EOPi8z7J3O7&7?P=T~*HGQIuE_o@s) znA*=K*F38uuN=2}3}L@4fZq^HgL&(cr4ceD_?c(-r~Ru83)0HxgDqHs_}G|;FA2x?!n{yN;=Yk`GSwac9cpp{Ro7&;2&>}**3*>&in0b7Ox3iIxy+N|^8xF) z4e|%>zoo=|4^J7mm&}(wzj+)&;XIH#CPFSO$2(3lEZk}tbxCIZ2t1UQ@!CsucQFUV zQ02?%Ssta17s7dzyriE_AZ-**ta+8ejJTnkwnEim{7BXbNrUn0T7I z+DVubij}*%nUCKSa|S?gT}g^+atE<8nrZ>8^s5+lqsQ2}iKBwSs567r1Q;iQ z@s~qa*5iQ4WF5L;s!K435jUwL^_5MqBPv7SgHU{d34_o(4xC;=;O+gCEefjEDCibBd{r`c8mu1}kc58Z{ z{s~R#ZUHf*A3~2`>(2={(GlOIi*1cz5_qm?#JTM0<%H8`&|&}05F#Nr-P9`;AJ{7L zyUv}mwt^6Nu>EL#2)1Hr~u%Kc32V^yl5c@=bA5+{BBFzc$2gLj<+48Q0b*! z`dv0#X{KI1eHIAv4h;LGA{_Tvox|$)J7F7xj0l3;wy?aX=ypct((5E5yc$$AaA62H z`rA{=JEnU7Q-Q$Kzt2Yb%3%3r6)ab$vB1ERkTNZFCh`%y?fTjiP!5YFc&#mc{%wKP z#u5;PVO)`n-Q~9IRLEKvb^oFb(%*6kX%l+USco&4x{lTXnEjAfO z^c=_=jhm0@JvyYZe*(|#HpC*-(N@LrhhKTq#$8iz9MPw?pwvI~N8rBwRxr7?Pfb}l zOb{aCn%3yIG%R)cFH`X zYkLEGuHZ8~G0yW3rV-18*tK`?et-{qJ@A&*{9akE8MaV&E3h!G*LQuo@gk?973EJ$ zeC4|m?V+f6-bT1yL6l9gB3cLH=U@&oFP3YcKzQ^>$pkkJr47yftoqzCiGlreIm;8I z(--ux96jVD-;nj0A9=6abtC1X<>7TOj~BRJWpD}y{0PTQj4gF(3IKpO^%W0veA+ee zQToZaDTI?f@1A|xOxeM=JFR-x|J69b{r1Q)_7m@_73Nk{R|fHt+UHn4M8%)mq_Y1PxQbu6TVw)s9zhTvchbLnSJ_h?Sa%C>xX+x5cY)yCC^ajoCkV1JSP5~pPM7UgI|cBAD=vxsPca0 z?gVJy&oiyYi!gTiGS>{%3@K!=J7)ti{tdI^_2LTn{CML5GSI9(-z3`mEur3jo1*|B z`Z!kChE~AiZ=STwe4CMgAgc~s$E2~(4mbb{uVjjw_`B&d3<3tlk)(G5YO2n8okjtp z)Y%pClRO3R9f?vZc8gGi`eJ&(ENc&-E&#u_`E+#+0B-~N#42YL9w3s;m8)Shrl|ymV95Ce;`J!`9If4+SthI~bobisQq6dE;X`cHd?9e6tefHBnUb zJQ;*f*x~4Unot*X&M38w=+e1hJ{I?wP(|cVlBD0o6JyBzw+wbCD_eJMEKNb#1zkv3 zFc$-sl};efBk%_}KUr_P-UBq{z19pFo5;+=wUvZN9&(8%^NW6q)5U-jvlcv^3|gFE zSa<}wt%Aiy2A|PR?buz;<_&C1!B?(nI7_5SpRTvh=2yNZdb%lKC4mH0plkhwyP5K6 zMfS$bmc;7+L6rJ~;@NFm*S9{d5vvPT#hVNu0I^oN{ZU!IKQhZAs?i4Brb>hslN z=|H1kwg>ET$J^72e#T%=vwscAaBimbo0=H=ig^@-6K@}Bxt~BpRd9MeJ2hj* z`s^&;VfWk{X^O)O^IdI`#pYQz#T!be!bW8SDWGRo-u@_>5Vc|q1W%6r|m!*wYXQ3w#z;m)B9K}ukU1> z9uVeMN!^EB`qU&iJkG2f^ue#t!%aKpceFs^5S&T>gg^@2>kP%hV=@(`Vz0?MdXWU` z*$4vOZs_aTdVDPsf8XCn{P^HpSu&W{VyXK2LIgwKS`P?dG(FcVLPGV%;E+&u+2Dz{ z(*?@96k?2dpDFXBzaxfgyoZ8?ZDtiOB2Km7J)tnOp=W3wgME~j0p#fhk?rqwW=~4H zlK9ZN&?U3RtiII=C@?ZLh>YZZisc~F$pWZ+pGAUw zN+U1)2E7zo0&SR*vv7$(%`ueP>C=+K}CH`WjQ=LuPuo2%yozuy;?!XlqqaP z%&JB9Ue3W?;>Oz!K61%Yd419+SN4hB zQ>W9^SVV4*R*nX&b(%xr+GB~i`i36;2?YM{yS ziCgPVpTl`KXWoF}>q6v*lVM)>ByaLo$`pGKZWuSahFx~=qGbO~C1hss=4|9$BPz{^+W!URW>VPG$iDsLsMwp?9u8~+rka%T@ z07y7@>6_k9FxbU^Jt{d4D%C2!f3FZ@sY!taEn*)5&bu`T%=v0 zw4ycW+RzLV@G}J2n!4JXUT{UvyZ7dP(optfi2=ns#s7e_=TQlT*E-xPBLn!p{vAs1 z^}+(w*2zbHbvhmh!je*-Fe+XkQr-&U+sV{4et8+*XLV&BYBtvGIoSYvYv1@+Me4@u z)0`+xO!_mIkO$cFrqw;Rs~XoM%YPqqS$T{){j}<603J$i0lixHZ9Z+PAT3@$r&Y-3 z=J7(mm~OtWEF``s?!<8W-Y0@5Zr<)H-zotMLq7UsQU2b#@$+%#;MS7xCp@( zYv%<`vIiwR^ifv_L5T8tjyKw?{Fe9~mze%S^&O{3MbW38@T)CS^M4g({XRSDKtfJT zK07WyAiXw@c`S6orMPh!U@y%`lJe0q@~Fc9`7v&}il+bc9v+vnhDkCsTs{I$X6=!X z@**}da~wMXScAhIcp5opREd{_Y6V3pwus7nWt`V|}=OgM6FJ?6#j$4#2O z>!O4+Lje|Oig82zmxnG(E11Yqn?@A+lhY*4kJQGq@au*%fnhVEBKOn>LXd@fQhqM< zPB@*lANU8iQNORQl@ivc4*KU~uqwa@YAo(=)*^s&%-VKINa}7-sBaQFoG$6jKaP!SC zQXb=1WCr|QkQ0+x?`$G*upS_jqeN63L;pjD0o@n`$(|9;wdZHMTPw^Nt`kb?w#g(S z2EuzT=;^>iU-8!AUA1bP19qF%hX*rf!;Np+_ZUDpjrC*z+#FJ_KWLgw{PW5#WC<1~ z>-)G-yb~ZJ;lBO+44N*5#BaHn?{#}QaY@T=jj{b@U{`q9v(-GDopRygtlQRb$vG}7 z9Rf%#d5U>II?y%#>wa}D00jS`Qq7IUOl{P8oq;+n+*XVHB1RKZ{tt4r&X z#j)R%rBuqUb$`V~5M8_iJFealpvN`|$3KXTst`6I{QsOR(5}_=4U{lRY9(3(l;>w!mvqRKiemXzG00Lk;keU47ib*1; z1YB6e{I95VWp}xNb9m_@XG}Sv5zZ|b=qBM1{=mDp90ZIzw3*ajB%QfwWNB^Vss+fs zIvx-AD7CV7ywXh69wkEqw4s$wmG;=lMlcOR+@>5#ay6G3gVPz7xO1BpIsEC2m9hQr z2h;BJT-9WwuM{t)E$KlttW+uJlh|~R35&zszCV|O%2+85iSc>>(b%Tues0QkpUVIm zu`$qf_Cvw*k0AQNeB$Lmu2((~;0^X#`RwyFe>{9HVl&W;Mo0l02Id7a!91q({?HNkhtcq zE;&EiKwQV-KAhZ^|0TozT;@-YUk&AJ^lnDOZxn>dT??Jh3G|}Q3J#)xp3CHVM=~QR z$s&6VIlOU}E|H^EzGt+O$VQuua6dRFtl`L&{!lIm6pi)1LuT`Eh=-NA-XGbnitrZ) zu6H+n1iFbMd-@-F{r(;EQ9#~srd)+zD@!W6oWc?jd^GT$ZP7;jxyn{KIpAAG*(Hps zMaaEO79Tu2aXCte>wo%eEI;^?h1hed8)@tjSqw^R3XB^&1?>GX<&9&LnD5lJ86Rf6i1ik;|L-H&0EfIj#BWd6~G1UBXsF^~u8l3@_Pg^|EsQ)RX@6R z3}I^bm4YRhub_J9kc&Jh|LR`zd}?d?w<)7xQ}ilLdYV!Btbe80xodb9!`zqF?6*JG zUd!wl3Jwaq(8dk@;S`bBdjgyae@?)rMBM571IJ&UimhxRb#uRviURt*)I;i?uXmGi zDLBPQ6D(dt&c@MJ&hdpXnkRTLl47Ba>fSfg4G{T|ivHZeh+Yl#rzSkx@x2u?48z70 zQtRi?nf)~W)4X|7M+s7T-nY>cjX;ZTey2-@HY^ zW{Jc^WBo8kA^L8|Kj+RA*@$53ckEQ1LwW?2I>wv`o{uW-fH7~#ysC+s?Cp9WZti_L zt7<-4nk09~iETW%k|$@`^hk;-6Q_D*-Am*1Ve=RAExkzLlyR55&nI;v{xrmZe~V!I zSqHz8D)*MW84}76R4S%)V7Yo`a@9ms7m}kcg8it#&hauMI{Wn7ge5;9F;=gu91Am4 zbp*`EhZlYRCF91yrNLJ&8Bx16D+TPXdhh-zE7Zo%#o(y`d5(~W|A$I%d~~m&qTKaw zc0hG}pS476x-;Vja9aK!-Dflfng?HrGjDq;fCLzSP91J&_3GP_l7g17h5f*UcTmR0 z@K6gLDfHZ?0Xyr>`(;KzGCEh6JgdGM`8|2pSrH<-)(i_OIXn@>e!Lg+PRTn}wR9~u zp<58!rwO>o(asPC7H*rxZop&05`_7403_6c;IWMaFc8DFn1piS?mnArXWl^1G~NNB z&c4LVf$D{8ttgQELtCm1yR5Ne0x4`;^c*Afa_KqY`2|6mu#{0U;@XxRV~K9^0i@>W zgli+5NOBYjD$NMUQ->lGk;#{Ec(Dc+q+zeQUa$2%nFn8hJV0w?#eTRJ2Gp?(A|kCu zlaK7?p__Hg$@fjbv5l<&klF*g+uV>3y-9&2LY5i8=1$P}D zU8TO1ZN7;8{ZHeWm7!(%UzI00yaC@N9lF=f6Fg*`Fv$O(Vp~D(61jGp0w~C8lNg9E zuj&@i0Qx1O%9Wvy2g;+b|Ek&K!_Y+o`DCc3mxcOwMx<5kmyR*NuSoR7TSvb9h#c?v z$1fQfCtsuL zo*5CTMRE-F2o*@4jD!j0>hz`gQud;!nOk<21aQL1=@36_#s)b zTH$WoEecIK^#ppFi<2E^X~}L%s_Z~3$+>^G>`L9?-4+6>z;DAbzE;Yq+E>PV`*}II zxmop@Xp^c=*#ii2EnZTij58wOd1K;1;^)uk5^gt8xUo?anGp8oC6Aji$h|L&{5F-% zc*Z#Ot!6IG&DjaLq^z08KJ?4=5zAJe<`<70RJ|`ZIUG(fe@}tO@_Nm#Df~5Q1<3r> zrB140^!@Hh_bw-H>oOxTHnwU)f>hGFox}LQd4q~64;d66CJI}!$F2rB36^MXDhJ~F zche?2u9jZ`Ns|I?o%)AB4~G;S(~;SJU1OwhnSr=Ef{5YNwnV}N+T6t-qU?nD5??`H zfn-Iz+=nugy|*T9dJ{~4WwzbY27Li(1D#M@fwAftx2&P8sV^hBXl^2Y zbNlQ4I0o4@0nj?gn9`gPP#2%)CJO#*zhr;dO@u+~1c8t>X&)*ZLG#qaiPjHsqj!(| zo$D2mtheJVU@`j0kTg=pxVGoB9U6X0(vLnOJTLP(|2+oUP}jyAL4GA8ZC?*oe<$0$ z15htEqn^4eYx^1kBnTn^+F-d%z+oCny20)D)>eS-lBt>ZVE-wxczBo7w0e z&v3W5lW{)kLf!J>KI%I$~X|0{f3w@9?4}8ilAXS5SZp>uV==aQ1u) z_uZb3jK?NH^~XPYkuGoOa+bOXh@(jGtC(y=gzHSv*=^U^Bin2j=O*78hm3I|2=ca} zaAe3o4iRL@F6D>86mMV=o!P7_`h_)Rgi@feibEK;EN>jwGH97imXC z|BKCmr9z`QPkBD7H!R#!RWmwtTmek6&5+=5kKNyA6CjZd*Qfb|mRQ#x#BPE8#V6o! zv89C6jKMP;+dYaxJTv|8!{IKY*&+&J;L67DyPFnh?Nm_q$fAiH8M5FT= zR7UQgph8m>pgg&m7~GC}<%{b+^U2>Ey5y*-{(pKlU_^ExQT#LkK~06^CF3mG)t344 z#EHQ<44!hbH|beiQaf}%9ui;szkBEpgTKnA)>12;^X7fJvK7FsK^!l5kv~|$mX2h_ zwf+6&(I=lO?JlsYlVl47YTbSy3fK?!L43l^OTMUK3Wj8^C zaMq58kJJJxdig(QjlRmJTLm@MLFuY~Hv|`m+@*Ep!gljyE1Z*>l2x1ZWKow(1_oqW zyDh73J7=d<_U+pjzhEa zcNqNyxUs;?$0Z$bL~gRMB{z8deY$tNX%$7oX?mSn%VbsUW!-|uNGZ#dy*gO6>z8|* z8SON)Uqjp0|LfK2VV`BxS;LE%?eQtE(C9(mW2Xnc(TOo~?5EEza*xG$;`SzWCEWi{ zO$V4%RVzT2^S`RTCRF)uO*_ns~#NE5R2JN7C^-w@sY6ddJv;&fT=itF5N z`_*KS{M|c&L8~aDuPTOeqy+g7a6Ea~b1Z60QoT{GxXdw|En&z#^IGIUNncmv-_&m1 zlyQ+u&3!QzC!GBf$CLvcV%v4RDWIv{>AQjQ7(Q_;`GkuXc)w$aX?j_G=|E90;4iW{ z7-+c8utd42x&XNl7f0<-!sQanS)Hx=f|U0$Uq$7PvD>-KY_kp-bTa?3TD72w7Jo%$ zd}zU6GCS<8F(q_lWEAT%IBqUaecpCf!ER_T$u}dN;2d6%+Z}>tKrsR(u?Ap)G)9|bt>XHQ7qeP=?pdpw$&v86@)i1mJ9KMHMod6yBc)+v&RAB(^UL6x4wsbdu{jD6>ahBeU9w z5H`9FBn9BV2E?oNLJI)__Ywy(&Nwl)H+z4R+i#Gp}>@-_{9F0`ow?PVTblLi`-Xp@{wk=5Zy5%*omB6Y=z)448PIGGF#rnn#B2OLy z_svAvY;&D4s|nZP2$z5x@vm4k0*_bN|KrhVk-&uIHegP~_oG$2H8up#1D?jRBz{fw z_ocBS^Vk1A1X0?+y=GCw@0-t$ae|L3xwUovP1lWSXQjRc0}bl;d@=t=h)Bajb)diW zITO``MKX#M+QY!7pxjniv?c9qlqP4eNW)r?$K~hO?mPFPF;wLLZ~5O^K+F7j*lw=% z1dFsT(BK9tv3b2OMt*JNY>6T-@z!are;hyuttctVh9%|v2oalKE4McJG|=Z(>_Lge z9!k+74Ue^f=0x)t%~?|`B$V7d_56ih4{8E{N4)5~-uENOwJYWCyvB`+!lHdq-&L2Z zs<%uKhyb1<^G2xYf`l~J#sE>_GR^Rl8Y^u>tK? p>DvelBaY7G9N>u)PHlhWAGbZdpOMKD2Rtcwaq=Etq3WB6acMD2ONSAa< zNxp+#*K^0DafGZQX zetZG{Fx|E9DPR=$F|2_fIFIGkNHsijcLlXK`1+BgG11(K3Wd7B>Q8^rB?YMh9*YnS)Ac`#7!o{|umr)*8(ap|{=dIh zwL}oj-k@)sr13w#_%L@C8_;r`eu~c$Ci=DW?hlR}CuREBN;;>?iMYJZ6Xuk|S^u2N zYB$R;QUybzpUxHYV>+Z^qzl-pG#euLZ71e@8>6_h&L@2*iXbI}t(DpC zRu%m>aap>>P%-94n{!N171LmG)5T!^z67qN~)40S5 zrjJ;maq6TB_pAOmFMcqema?jeFS%-7BJd#vO?MtNqVKr{?Mr74BPAEgzc^%if;%1= z3VP+)3!+&h3_Q#-l4juYl%!GfIp{J-#TGhJGWfbUJ7d^A&2y@6GJ(#){mYFu*;l^~ z8nFvAO=<#psy&Vy%UocO`^<{BjPY4=?dd4Po$Z!a>>>-D{r9}~ymqfhm48Ca)D*6a z)HuJFf!?|m8-=C0*A zf_a~6oZ*zbT*N{WecohOlDU|q^E-f|8+EoRezy>2-UpXd zmd9JW6&u+)I`=c70R!CKvan_?KK_w8>C1&24})E5)PRN==aC`1m8I}$_g}`=Cdt!G zRBtHXL_>R3!80nG67~E+uaL(^Vy!~%aQ}+$w@%}llDsFj?q;Bg%>25T(GN$yn2Zb__mu5L3=cTTeDZyU{j!A{B^x_j5YNdy{TTG<4q!`;DZI2hRqdK4JS z`@Aq*FaaLHp=}QQ_$8DM5e$`PiubA2R56b~c8Xo7;)fOw^0f zOqR=x*L&Psj8S(a>Flh+Rb)*MRliW|yiK9-01nlbqL(1T32n0~dKsD=_0`<&ne_OG ztX)zF={_aud6MdEv#X$VWrt&u5uva2g<_7f0UnDC$VP{^924gvMrTP)lb%L+`a(HI zm`nj&zlFEh4H;)2_&h$+B?x072W4PEifr08ZqY2I*s$hqXu)z}%3W2ovBiag^r(a{ z;J4sKD66v7hHxmWl`<{OO>pWy7C9i12^WSGf5QWF#66-Lk?S_CPwG3u}nf*1Sz5atmN4@tFzB4M*qWKOU7m zy+xS8(`S&visFyPQVTLizdMf;A2J$4eQaX8149sHwcPY96}mYgP3HJuux_;gE_hp$ zanf^_w0Q>iD-?kgl2NI?Gal`(xo+?b2@54wRS>DPNvCT|u({O^0k`jd6OyB~u4#Ry z4ofqSJT~lJG)5m|n5V=hzzsQ})^i6$#D!l%-{6!zem3}-c(a(RcdnGeid-dzBaEk( z4H~vU`YN%j`u-3$2Fsg=kyzP8?S7L#^DVKcmRS1lL|Mdhpy%;*Q#5tn!z(q_Z@7~% z;zq0WwyY!vg)aABL0o16@H8@{oi>fA+|a@2%a_IE`WgzYqWni%#L~uTq4* zx11==7)i&JpOo-&=BvUI?;PXo$nwMGc>_%GXBw|ad3le*FfVI;CM7=*F3@SEcBP}` zXw*|)lB(pOUH2|<8>pw)j`^9PKA$$mA##PJ9Veq7%`ZE6*u%V-?m~3xj*9{V9genB zXF60}m&WlI$I=p zzhAXY7svO`BpiOkbgmMG5jy^q{$%A+@yo^Fu#c4y(hQc!EkwY z`bl$nWP}T~=cLoJA+h5dIw~*iCBvVo ze)_d`?rt?(o&{fMiE(CgXJ)~D9eonV(Q-SBaUrAUol{(r>&UXH!S^N;2}zO%NxZ^j zk|c-Gt$Xs0_|Dn}%5q59jaLJ|IRqc>?H|&MrDC~rkDRnGKJFBG(55hjf8c0pFcB=nw$8Y+9_UY zH8x}J=7TuoKia%%88^j2S@NYrA`GXaOGDf{YkK$F=hx5q>nRW^?;pTX&&Rn9Dbe1$ zt5IZyReK9R=oe4WBkWLXv99LJbp;Q_^$9>e1g!#I>eRKiyHO_AARgmXE@ z(Q28Yoc-@^-0|EII}#w6yeMqZso_!ER0@HJI(c^m}btGml~wwph49fmt(hK!@H+NPPrq+srJ;Db~TAt6zD||IJrXAnmZ}pJ?)KsBP zI>Vhu*9{TPsI4+j_lb?5$maa^Ueayd%gT>zovP#eFBqb4tsfQ_ zsNz;mH#O%aKTS-T+LMpOC{O%v>7!xrXF0-t$4pj6w4l`8l`r&#n&UL3#L+ulnejJv zWc{@#XX^c27>tsn5JEyVYRR~*b>4pp%5Fxp1fEpnGhz@kU383_!aye#7qaePX(}4u z9!yU-tBY}R)+f6j2j}KmM92&dU0;DOMV1CKOFuhx4Fj)jH6iAbkZAQAi7agw)q4#j z!^N`oPHljpXr+DWF3VBN4oekfhv(HpVM39X_sO-eHS#DcTfXQQ> zpl6UI0SiubN?NU%D6H77qB}u_oowf#sO^jPqqm)x2vD>XBHNF`?!lh2WK9F_8&U*? zC=05ZF`Th2GEv18jF;vj1>K}tmp=u9auH@4g=G=xwIf@g8lL7XNskx1I(oA%)(T}= zXy<`i7)*Rz@a=wP@?=jK8g2$7tz%4*b>Bvp$((P;N+FOd7e>_L$P-a2Y&eQ$AW;sX z5bnFT|K&{s&+*6SA9(0`i0~C+{2mk2PWJxIkD>N*t}(X9*Z1Va;U_NwNr<{7I}@ou za>DX1mc3y4sKBlkJ$<>4mC4O9t^!9rQ40CS@wVf=LZn)+!!M+CdXPLa2?OzxNkA$4 zT|sJ!9(7|ul#2M9Vmqmkhew-B@ars?1Af(aj^%8M>@CcPShQ^qC6(WXPIyc=c_<*4 zx(JZww*1`M$!eODcI9Xm*84Ic{6P=P9BUdJ;iz>bvaYuie(O^DWf+!;$1c-aus?eW zFwLfC-%Dm=Ot-UD-+Z$3-Qt{V^|7&{z#UynJ*$o5Wwld>4 zyW^?R&6~!eX?cAaIxI8|8l=r|LqeY#0q?r(EfDRuT{>#nBEC`w40*o*quNehbY zye=>ACFA#Mj|ttybVN7D=u^+?n`4h@q>$;dLq8=%Z_r*6l89-(J7fz93+DzC|Kl08 zT0xF+?KV1YLB6EmDb{rbqp(6Jkg*&JBI9w(Z+hRooz_LPRpa!89aDBY=~+D4FkBvUI`Y{}y`_}AM~2hVq{?v>UBco*P-pTvbElEk9odokHTtz*0eCe&^&v1sU_B|qThPdPcokp0>7A67 zBagx~Ws%B)(!Ap9#2M0u$?!M1eP`4S_zwsXGWBi_qvOO;=JYe0`r*vIi5c zZ+>@pzdHY<0f!R!Hj_l>eRaa1*x!X=S8k8nI0`7dr7W_4rGyFMyKlj*8+V;pIElbK zKcG9Hb413%?+*9w8lV1tQ`P{tK`k7AUeUjbjY{yIL|;lEkXYqfHo?&m-YXkbJFKZY zCmWqk9={s`fAc>DYvU(7^+aVDhJls=kC{%E_Pohgl~))rgwQh?r8ITWAk1@a(`BP% z_<`{59nZrVCki z9wHcm@R6l)fBL|0cqv`pW!XzQ`eZV_Bq<)lVQaE*NGy*pP2`moL?W<+qs1rdM%hP~ z0m(5oILdOB)Xmss{wQ@>wcycM0XK4Pwzlks1#-31Y$yI%zJO{oG2hp6RgcqUzh~pDw?IkR4((OyQ-JHBI3Wp!~i7il-j$r>x zvjU6n`v=I?8(O4{nHYw!N6|#ucxp<~3NJpkj^_ru(bu~xZ@Yw|?N};N5u$1!Mw&ZJ zE6*^o%z1GvkYe_78AmS?%PPzcZ!slf5Z#RZZt7u@FoH!BqXQ%=jqM;jOc8At-eCxDuxS6W!fDe1k^*1*7|eDOASc@P{`F ztb7TiWRCh~k7Jw=A5~$sH$VeO!x<9?%7Y18V*|a3Bd8FAw`pMrf+z~a>|7~z18R_O zvM8CCXw_uin{YP=^9i|eVj~tyMQlK5OSi48=}6e7fWcj&K%SZ)c_>d1~KP?O(zalAg>cn8cG@Gx9Co20I4 zW#drt8|?$5hnZTh6Sy7x&~e!FZ_4nim@r&tEnQ7h)OEt)1FpB6-Npto|cF14RB_*r+?PXI^94>xRmFKtT@CM5l|uc ziWz1)!2H!}l6X}1=SZfL<(tBJaXi^=e52<9CjJct@J9wqcmP{a)RzSQTd8 zTgmlBx2r3>L(jtexe2A(W|(~)yr-1_=}1Q-4E@FJEtR;ehNZdM&HQTi2bHU{J$<6s zvUX^_v%aKmX~hBwb~c*Kc|#GyH^S~@*{2Kn9{%Pzx?6J#Eth)J;j+86=Kg3;)sYv? zstp!V9L?+79-&E^8ET`9N;F|}OFN6VXpFdo&2VV-5@OF|Hy|=MjfbTj3^Dy*oI4r6 z_*)JJ&HXl=INmHZHX=(e{DW7<#J5!-mNQSsDB)1AJ!P1=dG~b1;HO@lvpAPnC==Fs z-G^Y1(F%>h-zTKy=GaR#rsuDwC1?AN*J>;aKGkst8Eo1YaMs03YAC-gZTqW7_}NYB zVlyhiez}w~hm%TO{L*N2!&i2nU2PZ!_LTQ?`wa#HYpe-p;e||iFepUsRrtBOaTgW; z(bi=V`rZ^287AKuWlEoK5b^YJ3>&74F7sIE4`%R z2dOqA)#z~H3RB{w~aI5eZwbtMTzXc%{OjShM@B?!^(b6MVE@$2b8I&@uRTj8T9E> zE7qDwv!fl7R7Rd)<2P`bmDlg{Dkg&W+tN7GIMQ9yH%M_zY5o+|%|a`h@n7yrC)&8`V^4JNxKSpVrMP^_5Q|(Wp|AS! zYDB=SNvJYeJp*nWXz>zf`P)9}lBxoP?lhNLq$5Q9J?8|?q?A6;jg58p|()` z9!)z)Qns~*K;KkYVz=KceLzPM-fO^`wlC1jD!-%1xFVG%z}$-u+Gf_O@g}zdsUXLS zUbTtUovqeY(WZOzHyGv-tz(0NZ6Nn%9`p1EGUQ2u*sFu*zGK7CoYL5`6(8eI%%!1#idqUkYS1N13nr>CD3`af9h8KjOne72**jRMo zOkbDO+Y?#+X3e@SMN9#iM1VjN`6z{y-Ohb%N##zzE&}E&T3gwwlj!sF zjaVFFwwah%7YFH^G{@{cs00Z{EvL10J*_4<-DO#C=flB&4{GteQf|Tb+G^dv_bM+f zwkouPA!@W>Kg{cb+FbGDJr|eB8Osy?|JSR}A=SLmSXg0cOJ3EOP53@vcxNR?E~+V%{~opsl=ezq&@6j>iJ7 zJ`-Dl)v}2TCpKeaA4+X3sKWnZ=j4Dz8~gLmo9Vcqmyt0p>Cj)9O~%PB=h6BtFLO9Y zH$~i6U3bryH+}?c%lbR|Ym$M&b}evm)wQwLox8v0qChx*jX#xY9BvOJ&co} zn(4}&FB?@cTRvseO>l?F)XV1(;m=4c+}7Mn zou2$0XY9BPl`W35`5W`ekW`*gj~Q-nNL4O-8mj^NZ&9^82H|E1J0rP-0ov(ZkMk62 zr}oNi+p;u<_f`0N^Vtx^&ZGKkI%1yRBNA(otXjGMml@5Zqxsy~;pFjpb$KT@Ky>lw z(gvNMb!T3^o;iODl#@X1%mfi~RPKmYV*3~KjBic{#cUx;^+ZvvCIwWukh0mvtE}R| ztqH%*BgtncYaP8~ot4rf9#@r^g>Y#<2Nncb%fV%6L27W1`(a6Xr>E;)kJyiNxwzAj z?SRgsTO)dd<)W7cN8hm!$T)bVV>GxHp&IOwk8TQC5*KQsPG&EMGeb;awc zg$D<&N6>uua17W7{(M!B`Jv3tMjNC);*RHngkDN`$9r>^2fi-e4}MLB_kxamgb-R7 z+;FnuHhhg9_nPZ6cl+aB;+3GiDz%7zTs4)FTDv_fehuY#kgKM+bh=ARE`%-4k;eiwXGtn=DYs6)-zkjkXuGGWSEAxFS!?Cx z`uNDSv|`H0tzz@ft!55rJp%;0`Du`TS?XtFr1@Q3+cz6$lq_v)0XN1rfB)Vy8{>sW zCfhC_!?FRCT8)Z$)v-)T`S4=vz+f>AuzCB7Z{eVOifogTl4;5By`?0wV3)ZpHEc;q z(UXMk50JMcKB2S{u7to&%1!$cmVKX>BQUS&-u_=r#mtiqqO$jRMH*jKyP7ku*9V1A z;imA*(}obY*zb~th4}e6BH^fWZ%$}dW^?%m#$VanC`g}KJ$b$Vg{L}hsNn81--VH# z&Py9IPN*ol__3_Tmyx@@zH+rO`)TEJ{h0IG4Q%R~#0{hi7x&`TGT{Xu&n|y}^o8n57h-Bt%u8u~sy*gNbI+44K+qGZx!dsnu}^kz6}fh}rbC^P7z z8%knlrtp(sO#>d7=YYi;F&TWrRtOihGau}gLpZ(5p4x$ZfTJilp}F2xUvD}J+##Zw zJMF!O^|XJN&0mv#-tt4{F4PH4Q5)mivr-65`HVVm9}fktOQR!ea;=1 z7fu-;o*=_Gr!c?ubWTN;cd@quo@)2onIO%J+j~Z0?(g6zDnNfPOr$llAA2Qpn~rba zDzzmSp?T{bRfNc zl!F$CSEK5ReX`+Da!!=`MX2di7+i7Y0SkkKp!5f&&{^p04;m|q^=VqXs_}QZ!*hxy zSXO*)29B`sI&T)}EChQ|3G?*Ey|?_IpJ+vF5}{&a1Gdc0@F{ynz1u@{&B7Lzj%&i4 zNR1Sc)zM7oT}MH|kFA)gidyzP)DSC?M=GQ5H)0sE=z!2+r?X$+YY*PzNhvfsbLC!;@`TXVxI(t)Crvl-VaI! zU?Q<{cU_C{Q=EE5-ir`-`S7npX<_p03?BJi)~rE)SYr72HpDav{9vKSD%l(=dT2ZH zzn})I`f>@sv(k|#>GmSM6H~PY0c7WMM3#RT+LhWxY0!_}@JvzAEtfWZ6`CKsf9&=; zgvHbdY&BqwUFCC|x5a}_`-CM}zph0AoIpox&{;e`DTWIsCmle8tDWx?I-YQlDpEMO zS_n!I_;kMW4+RD9C}Hke$Gxs|GNZ=N7PjX5`kQn6LwhGn@3`3R5Z9md>Ns^6zs#o( zEow-+-K4m@w3pv0T2_A*6_l!nauQ7`xvAYu2uyBacX^oF5?l$uL$l(`a}(nmYU|xk zv1XN!h}8-)^eTCSZjW2?Br(fZ0w5&Z$RO~Ac)vIro7EsspZ66mB2)_z^D9)e3BVj~G`sAg}h%O6iRwGld7uj^R`*r+- z&&le?pDV))+OE4@nP3x+q6jBxh!QlQ(>XZsBaz+mO*86UN{twnzT(ZgVbT#`!a*w2 z*|SumFAqewuTyl%Qg1AaQv+31EW`m{k_mN|1{k}_SMLl_jlzZQ}X_zE3 z>uJx03CGSt3f@{)YSfE*@nH9_bhfJ4VPt_Te#7}*jU@2~8R9(a1?nTNV49 zyQo%ii$V?L`N402mez&xSM5_5_W+Y%PJx?nR!jU&+lChZB`_SAE!0;=P@GhsekgJ< zOND!Gy~dvjILW`8>8BOT7OW1t%kS_Xovj2Ydx8W*ysTMp6w9?-!Q;KR9(zi-WAbji zaai&LFHqP=wd>&iYwC86*pq<#%Y{FS7@j<4dj4_xEan1m?9A4DfPr{{Z)f2c8Uhlu zq6cU-Kp>Lz2I3HkzOSr~+G5hhCw}yn>e!EB&SjComA`T(0O>j{RE&C|@aK2~=a6LW zTX%la$|EsQP~9i8-YjwcPg7$aZ%BbG^m4wU8W26VJzK|1pm6A&`_u@OE&YZNKx0%@ zC^;(ErmDjhs;_c3->6m8w$OIR)^9ieYH5UgD6l8!-zY6)OdrsKaj25ArWVS82BiPw+B&Fjr%HgG4Ix`DH%NcCE!s!9s?do_^d zZ{H?@t>~<(5UTR6{DF=9Um*;zlJ}9pyX80auWIcXbf{n*`lQaU07>=6Mm34_=B8M9 zOEn_pOIJl4AHUy^M?{Fhwii{C_F7a1je>XCC)iYkNI)FnNEwTUN2tS?rK?RkaHLUa zYE(-PHi)t0A9beShX1fwzT~$`pVY(o52k^!YQW@3oUJm;hi$n~rl@={K#sq8A{e}x zG^i5%nR_3>@(@DqtEC0T-nB`SYe!2(2kGzOr=gcD zR}WDz0W7%#RH1)yM@2SJL6;SfBYsz-xFFyJ)pyFbGlc2&Jp|G(VqWF~2jJ!65|E7BMOdTxJFhszy%iDhfL)x?O2;sQ! z4)z~er);RV!)KvmE`Any!kifsrBSU_2KALcl)0c)_S!IN0W-ZZ>S)6rCmTg1UM-Dp zD^XSTfsdX)T-N$py#vgva#vxAqesZGK<|Etlv1|e3uJxZ?>@<5xW{ahIuM**pyBtY zDYrJm)z&WtrDBc%7NN^aIMb8QfcvUx*EL0E6iHyNlOh=O4#d|RgVMwPwU}S8LJ}D) z-!*p7^`)Xn5PBU|K3ltd|1XqOqvTV8d0Rjs&zIebOFzo`4px^e=fI8s(_J7l7+>Dy zn8RHYUlF=ui#MqaTCnA(C#zBBZuYl%m~P{Ol`WjUnkdVHhJp5T zV|OQTfbgFYGiXm31F`_e&o)r7GWB|Fz^JP~tf>9U3eEgCrt!mQJ8Td#O015ti3ZPv z@ID^<7iXG*kl{M&E3~^{MBVxxT(WZ_sLw`7PGU`q`FFMvlts062_i<*8slSpNAn3E zanNYPwiS>Sr0MYH?lf5mW=O8hpW5!Z(#b?m5^-HyY-9~(aV$m!lg04U{)(N?YLWOO- z^7@85Pxt3-aR`E8(H5-(+g=EV{~R$-lGkQM^{npq6sQoX!qUZw;PP`3$+KhPt{3Gg zBr>eh%-uUrqjDK7Z=&UTPV;cpFD@mDd;+3X5R=tFWr?$8u+=u!AaZ z&-%|h6)bJJ5jxMcQV4Fv-1``dIvp>nc7pdU=7ES-LoAY*VW*L5?NlbrcY-0$$o@D zeiI*@5<@h;&wg{Vrma)HN-&#OGFLn9l(GfbTAr=~EP{qC(C?j%n1^gR!uOQLC5V0R z=*1%PJ>pMq{Lmrpi75{JyTR_~hCeY*H&4as%(A8(e*S_{^nSI=l8XuF>K{w3yE|<} z+xXY~GvA-~7dD=)bJ<2<@hu`pP-`VtC|6a8Za8Qk5|^unqE}rGU$NsASqIFy`TX*M zxoJ*dD+b1v&9mtLU9Hs*sJh#DXO8smijuCs9pcJVuH%JDH`JA<=#Pez20*#cykLIM z&fL`LkmN=nu@jW)y3El?0xZMY4U1YhG>lltnLXa=j)8XF;M~bTVRN~#%JX)eYUw@% z>H$q`C^|n4C@2~vDf3f8o@~98AEME&HDJED^kTfqddwB?MHH4|UBkwm-ML2{yKJy_ zn-7NQV4h+V$ak14axOQvsO5lK<9uI zU8*AT@E#n6PCVhvi1hFNt?_Y??8t0CiFaovUeX~)UF|X^&iB#4PbrW*xy7kTw4TOm z27`Z&F2u?sdK)3%;Btn3i`1ojI#i(m^DdW{pM2sjR((f0#Rs96IkdfCnm)mfJo#cA z)sR6R#^jf1HF-6M{n0|G{nMdd=pLayWPea3#xIg5S{X6_`8iYoLMQ|)biX_! zOAYPnQ_lrGcw?*MV@b`iQ?MIOgellM_M0;xZnQ0Zfj=TLl*Bgho{V6Np&y;jL9yUy>DD{Y-|5 z7RO~#aB&D zbdt3(KUh|+{;KTa&ruuqU!Ix^1=zKgg`t&e-fp)|Ql zr2bRS)xxramC=M2%&fSOlLsDL{q(dlIPZU~Nr15Tx_WuK)1?{R0&F`m7p>=jTK{?6 zlEXO@xp3LmRq#N<`8(ZYPXv1R^QX!d)7*35B7&&R_X_{F0eq-v{bESTY$HjE{w2|6 zy=U9F=4Y$pnYp$+T*?npfV2c=8_2KOdY$F88LxrsKmd2h;=;EFQYr5QBo(>?92?r` zg$g*M%v-aYtH7=hS7w4fZjv}|9#5e*Lhu;DRs>9u&zrAU(^a%=lGlZpDhVgAArTut2xciyU4RTZs@epqf-F$q%U5S?SQ8K zcod=W{t$8Plrl3K#|PmJpod#IpqZVP7zmaDBH32nlO3>M{U`UiQM|0u`<{2V{g6(6 z$Co*$RRZYd!{-bnmudWqg&^AXId)0E<|f18?_`2L(kZs@^qW>YZgL)b8Nj4+xVZH^ zfy48VeyV=jZ~q7Z^H4-4d0kcFgFVSK5{sy*59M<;uOnFF9$!{6W#-(g_JdL#g=HS6 zRKj4UWAjC>)c^@Y+=Zj|0hucgxHEd>X2uLt{Z)0=Wvs4t&42Uoslt3mJsv^h-${!s zfpnp0_|Byh1qi(Wk#ZRe(-zH&1~a*gB9Hu7AhdwTFbRGjr6Hx~dEAK2&2v8@XPrE} zklzR4eP^jDQdg*3m zmfE85v^*vl?Me@t1e|3QDva~TOLtZBBo?xsb_W2;jwa`^FlP7}>!@4s>|P8cQ+r%<^UzXwuK4{x}kceB5#xZo8k!aRmk&W;zB3)i!Y zdu*1XQI^~CF1Ra#z?}T0h$evwrI0YP>vFN5^gKYY>X!YLbj5r05BXPcj2RAK01g@4 za~e%Zj6mlT<6(LHZhbEs>tutBfTQ>yL>=X?i}k{x@^2!TEI1D%s+W`JCeEYVFFp@l z*8p(D3Ao9X`(i}glrza0Z1{3WT}6QhQ@t~Vme*9|QkNgVc~uHcic)fqkB+dQN-;{C7}^qKP^IRrXVU%$x))7U2s(K?&_F|EFpqvoVM}?% zSzgD(LI3nH$bfhEQ%|C6nh!|*YA_E%ONSoP@J&iPw^fLl#O{25>rUKDbmS`%)4Lx! zvJfxXjS&C`7$Mq*s{kC{9ThR?<1a%_H(fs9#d#EnNTC>EM@d1-#B3rJ#Mj^Czmp$% ztPSgLN-{(H-7r{0YvS>!#l9X5_!Egq5RVtGe(BZS47 zh8)uSxgIPa)KrYye&}8uMsXEzOmvEG>%M@m)3uLQAYmtc)q6KktrU)EjJteIv0fV8 z`yOOX3aG)IX$-w+1rkdur@YsSFJia!2B-j$Z51XP%CKZEj+mEpLpR5+0rt58KjN*g zCSzxi^fK$>@B)zmo^}VX7Nj^V?Iz$Zf4=%>~OR8eGHQ)MQv$8d3*Kn;4I&79}{) zHWLFk{yJm(=s#4^*dR_}fd?#01EtJiBI1qTPvR;=IQyzk#Qs3(reBsFNr;l-Fg_VV zHE|>TZ?>C06!NcWPzCWf@R@%qVkP*+o$qhZCY+xiNmBJY)-{oPBj0tR!iDV}pdZw}+IZpQDlB7rx2UdnO&r zz%j-oEdj#M`g#N|8#RyX(7d9aPS>3Q)AT z$T~U4$i_M27iUb-eS4P)y?gn&(#!o18wB(FenLr>(9?N#W4qPpKELPIKi?hdt+1Jc zvpJmtFF2lG-CYx_zdBpWdXjLw!vH;cKvenaa~H?+ev(F2A{MTmTa4|^FEyYRS=mq8 zPH_L3glaQ<8NBCd=q&3Ui})J2GH&2}nBmi2MD>HiJK+>BICOU~iMAIV>OzO_b;d^2 z#4_9wq?>Axq&z=%)1*q((4hNw7KqRM_qbN6jl0O4OYiTms%BTs>vD$+QzeTDHYwJ1tgtcOyonmz%s;IzC zsyv8Ivc80Nx5|yU`~I7{diU>h{H-c?s=D%M7-gyvTXoNZlmk*7{Y@4p4(;{c$p%eY zBqb*lDIywa^sBqcJes=b6noXn^&8rU{pl*XR(Q_G+7_JnF>@ba*j z03Qr#`)ONf{o_DS&cJy0j@!q50m?-FfIX|D_WBrL+rJ~&*oL^29Nl2y`}Up2hCR1X z3q~38fO$GHu)0b!+{-#=#53^9$6t@Cs()Lw2Tu?qZcE}SO*GDnEUf>TOF>MGpn`ue zIzMtz_Q5A{$uSKl5jr;gYPS#6hOIcYZ6Zc7PaEsM=sPv7yVABX$VyiFP7e|Oq8P#j z%1U)WG4)E2M4iMq^&oQ%R^P?pmg!HpFG+a@IHz){h}3PajG8y!3l#8MJ>SF65qcWi z+Z1?nE_t2?9BaD*uWRYM82=y-itgX4y$2(!Ppp2o9h45z)IdfZ6pko*SVaIKtZ<$O zL?`+Y9v$(f?ngAdt>I!@pLv_ZJ>s8@tB5NVm+#tWokh~*f%kSVvlB7jGt^;4F&WzO z^7|8niUH*7N7e?dSWUFI&*#+XFdvHfa&1`HiDnLZ)oWKQI%m;{!jF0-aLopq?UqdR zvQW+!KK!xGXe=oi&DU7YgT$={T2*~cFtbFy@F$Xkt)D&*sUyH z%^zA}YM_j2U25x!2arhH^}IA7* zbTBA(r6CpZuS(S^#OX>SS>gx_EX?}AiyqPt2+>KGX6C5lm`s$bRmosE9swJ^znON) ze=<}!{qjHx8fc}wL<@#s{Q70fO7ArtJezsu7YiRqkKDjCuTz&sf|HC(Ihe?_*rHx7 z*cuSPmBcPjMZz*$iDd774lj^<6CwM6t_-9~1As0YULK|JBIcPS*SQN9G$7`jU!4lM zDwL2f`0#piTuT);Q47;`Udp!dw%eI^%;?Km{bIJ%dNV_HIEiUzxmwEXkCT(wq31!> zd88?dN${RkDS3B7o*{vfvdp%la@D(Kpz_XlqY)Y<$s#+ zQ4MzD7zX?vg&MjwEZiXn49m?2qaYOaN zrIDASwIQ?P4h5%;o92MKU@y%{y@G{bv;N;1X>Vi(dqB}h?&YJV43)D`ESt} zzC$K7-(Du1A>^;E38a<_xqsm{+wR!}L1C=TBj{(ZD}&(#ety1^+v4?n=Mt_AM?bn` z(}OI|om4=;zzVuzy2Ai@AJhD5mquAPUV%V4C?a52hPuR9=c?}(9uX*+bm2}BRXAap zotqtU6=E7hMRhL;j$Zn`G;v-)15OVuZ*8MLW1!uO1?(*}}?PJz* zq7^mwcRB_=kkXCfMR%M2bmh9mL^I4tlyjr_(qF_lsNJVt%YZx1;sYU09C;pKk$UQX zbe;qfWyn+_l2;ZRE;>EH`uR*5c6w*Alj(1=j5{+t8-+%DJ7KQjYE901%RWJ=XlA9w zFK~3H(9+`sMQ_1$bYv$7s!gPUb9ecMNK3y+PN!S3P*4uFYTe03uA2$|8aHUzbqHFW z^n6Q%Q1?YVwy=kI1{VT3UKVAkV-{2@DUnk--LUF;veuqmHW!q6(8)=>HSK7uuTZP% zfS8AxP~%Eoy|3pXA;DRR)1>LD#VCI}Gc}RZ6XHgVriLkBwuS)Z z&?W8?mt(p?k$Y9N%Uc^vcvOuC+*H4gZiv)H#4Jt{Ll)gGH`Pfk|K+5MMqSo)RyigEN2sP%!GEa$m`K%PM2}%m|D&HTw_Jpvb-cH`rr+>$Kb}y#$ zt5{$fqbXni_RSn+AF)-Hi(|DIrn10qMdQ?a0Z#K(nc{_v3HO{iv@1ZuLznR z;7Ig2n%#6CJU=|swVc3Phu1^5}DW!a+)rer;P0TYabn#S+4h5Tp)QH-<{ad!=N5k z7Gf^yXUe8ExT|nq0_dWOETti8tn;h$IB>87CP3m4iBOa~OO(yXk1l}6)muO2z%ON{Q z6I;7yQ}7&Xb93`AkF#4a_AYqt=t1r25lLa& zq(KOz`tXeYC!w$0jo0`}cz+bYMwG8C4sHu3$+$zZ8LY9VrSH6NAB4E_KaO`I;HZkD zSy97V69+zCqC!VU6C20gN^>iw8$S3ffq*;}KO};%S{cPEk0Hv#-r}*e>ph{yU1ef$ z3)J$ncDyxS5ry3l1~z&nn7N(x=fRWO*71}r`B`QiWqg+DEFq+0J45Jq3T4(!tk5>X z+n^g1tU|fHiq0FM~*c0hrH-G0}QY`*bl;wrQEZq%I>>Aa^SdbNUN3F+( zqy{*nf0M(~-?9jf(WV(jI%IOIRpSGEU|Ej1NILEMa}mtw3g>BGE_@wWa058+zs5N0 zU)Qo5q6fSBL(;{K$C9gdz%fx2eSO7DK`RMV*tVPZqKBIz4f%9lXrVbP!IroUAr1)srq@X4&{?xU;k5(QOzTMjD!p+Bm;JYChDhLgwVg>W( F{~tRM$g=> "$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 0000000000000000000000000000000000000000..5a766ae76ece5e4afce6f997f4e9aa351ec654f8 GIT binary patch literal 3843 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 (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); + } + } + } + } +}