feat: initial release of SplitOverlay (KeyOverlay 1.0.0)
CI / build-and-test (push) Canceled after 0s
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
Reference in New Issue
Block a user