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

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

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

After

Width:  |  Height:  |  Size: 3.8 KiB