feat: initial release of SplitOverlay (KeyOverlay 1.0.0)
CI / build-and-test (push) Canceled after 0s
Semi-transparent always-on-top Windows overlay that shows the keymap of a Vial/QMK split keyboard (Silakka54). - draws all layers from a Vial .vil layout, transparent keys fall through - detects the active layer from the keyboard via a Raw HID command (firmware/) - shows characters of the active Windows input language - can split the two halves into separate, independently movable windows - highlights pressed keys; tray menu for opacity, size and options - Inno Setup installer and portable build; GitHub-style CI workflow
@@ -0,0 +1,6 @@
|
||||
* text=auto
|
||||
*.sh text eol=lf
|
||||
*.iss text eol=crlf
|
||||
*.uf2 binary
|
||||
*.ico binary
|
||||
*.png binary
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
bin/
|
||||
obj/
|
||||
dist
|
||||
installer/packages
|
||||
.firmware-work/
|
||||
*.uf2
|
||||
*.bak
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -0,0 +1,193 @@
|
||||
# SplitOverlay
|
||||
|
||||
**Русский** · [English below](#english)
|
||||
|
||||
> Название репозитория — **SplitOverlay**. В коде, в имени exe, установщика и в папке настроек приложение называется **KeyOverlay**.
|
||||
|
||||
Полупрозрачный оверлей для Windows, который поверх всех окон показывает раскладку программируемой
|
||||
(Vial / QMK) split-клавиатуры, например [Silakka54](https://github.com/Squalius-cephalus/silakka54).
|
||||
Нужен, чтобы учить слои: смотришь на экран и видишь, что где лежит.
|
||||
|
||||

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