TASK 20 step 2: hotkey config UI + modifier chord persistence + conflict detection
This commit is contained in:
+53
-22
@@ -22,14 +22,14 @@ public enum HotkeyId
|
||||
/// user32 (a real registration is global to the machine and needs a pump).</summary>
|
||||
public interface IHotkeyRegistrar
|
||||
{
|
||||
bool Register(IntPtr hwnd, HotkeyId id, uint virtualKey);
|
||||
bool Register(IntPtr hwnd, HotkeyId id, uint modifiers, uint virtualKey);
|
||||
void Unregister(IntPtr hwnd, HotkeyId id);
|
||||
}
|
||||
|
||||
public sealed class Win32HotkeyRegistrar : IHotkeyRegistrar
|
||||
{
|
||||
public bool Register(IntPtr hwnd, HotkeyId id, uint virtualKey)
|
||||
=> RegisterHotKey(hwnd, (int)id, 0, virtualKey);
|
||||
public bool Register(IntPtr hwnd, HotkeyId id, uint modifiers, uint virtualKey)
|
||||
=> RegisterHotKey(hwnd, (int)id, modifiers, virtualKey);
|
||||
|
||||
public void Unregister(IntPtr hwnd, HotkeyId id)
|
||||
=> UnregisterHotKey(hwnd, (int)id);
|
||||
@@ -46,6 +46,7 @@ public sealed class Win32HotkeyRegistrar : IHotkeyRegistrar
|
||||
/// Defaults: F1-F5 stage/transition the five canonical scenes, F6 start/end stream,
|
||||
/// F7 mute mic, F8 mute desktop audio, F9 TRAX play/pause.
|
||||
/// Attach after the window has a source (OnSourceInitialized); Detach on close.
|
||||
/// Pass a custom bindings dictionary to override defaults.
|
||||
/// </summary>
|
||||
public sealed class GlobalHotkeyManager
|
||||
{
|
||||
@@ -53,17 +54,17 @@ public sealed class GlobalHotkeyManager
|
||||
|
||||
internal static Func<IHotkeyRegistrar>? RegistrarOverride;
|
||||
|
||||
private static readonly Dictionary<HotkeyId, uint> Defaults = new()
|
||||
internal static readonly Dictionary<HotkeyId, HotkeyChord> Defaults = new()
|
||||
{
|
||||
[HotkeyId.SceneStarting] = 0x70, // F1
|
||||
[HotkeyId.SceneLive] = 0x71, // F2
|
||||
[HotkeyId.SceneBrb] = 0x72, // F3
|
||||
[HotkeyId.SceneChat] = 0x73, // F4
|
||||
[HotkeyId.SceneEnding] = 0x74, // F5
|
||||
[HotkeyId.StartStopStream] = 0x75, // F6
|
||||
[HotkeyId.ToggleMicMute] = 0x76, // F7
|
||||
[HotkeyId.ToggleGameMute] = 0x77, // F8
|
||||
[HotkeyId.TraxPlayPause] = 0x78, // F9
|
||||
[HotkeyId.SceneStarting] = new HotkeyChord(0, 0x70), // F1
|
||||
[HotkeyId.SceneLive] = new HotkeyChord(0, 0x71), // F2
|
||||
[HotkeyId.SceneBrb] = new HotkeyChord(0, 0x72), // F3
|
||||
[HotkeyId.SceneChat] = new HotkeyChord(0, 0x73), // F4
|
||||
[HotkeyId.SceneEnding] = new HotkeyChord(0, 0x74), // F5
|
||||
[HotkeyId.StartStopStream]= new HotkeyChord(0, 0x75), // F6
|
||||
[HotkeyId.ToggleMicMute] = new HotkeyChord(0, 0x76), // F7
|
||||
[HotkeyId.ToggleGameMute] = new HotkeyChord(0, 0x77), // F8
|
||||
[HotkeyId.TraxPlayPause] = new HotkeyChord(0, 0x78), // F9
|
||||
};
|
||||
|
||||
private readonly IHotkeyRegistrar _registrar;
|
||||
@@ -81,22 +82,52 @@ public sealed class GlobalHotkeyManager
|
||||
|
||||
internal GlobalHotkeyManager(IHotkeyRegistrar registrar) => _registrar = registrar;
|
||||
|
||||
public void Attach(System.Windows.Window window)
|
||||
/// <summary>
|
||||
/// Attach to a window and register hotkeys. If <paramref name="bindings"/> is
|
||||
/// non-null, it overrides the defaults for the provided entries; any HotkeyId
|
||||
/// not present in the dictionary falls back to the built-in default.
|
||||
/// </summary>
|
||||
public void Attach(System.Windows.Window window, Dictionary<HotkeyId, HotkeyChord>? bindings = null)
|
||||
{
|
||||
_hwnd = new System.Windows.Interop.WindowInteropHelper(window).Handle;
|
||||
_source = System.Windows.Interop.HwndSource.FromHwnd(_hwnd);
|
||||
_source?.AddHook(WndProc);
|
||||
foreach (var (id, vk) in Defaults)
|
||||
{
|
||||
if (_registrar.Register(_hwnd, id, vk))
|
||||
_registered.Add(id);
|
||||
else
|
||||
AppLog.Write($"Hotkey: {(HotkeyId)id} (vk {vk}) not registered — likely owned by another app");
|
||||
}
|
||||
RegisterAll(bindings);
|
||||
window.Closed += OnWindowClosed;
|
||||
AppLog.Write($"hotkey: attached, {_registered.Count} registered");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hot-swap bindings: detach current registrations, re-register with new
|
||||
/// bindings. Call after the user saves custom hotkey config.
|
||||
/// </summary>
|
||||
public void Reattach(System.Windows.Window window, Dictionary<HotkeyId, HotkeyChord> bindings)
|
||||
{
|
||||
Detach();
|
||||
_hwnd = new System.Windows.Interop.WindowInteropHelper(window).Handle;
|
||||
_source = System.Windows.Interop.HwndSource.FromHwnd(_hwnd);
|
||||
_source?.AddHook(WndProc);
|
||||
RegisterAll(bindings);
|
||||
AppLog.Write($"hotkey: reattached, {_registered.Count} registered");
|
||||
}
|
||||
|
||||
private void RegisterAll(Dictionary<HotkeyId, HotkeyChord>? bindings)
|
||||
{
|
||||
foreach (HotkeyId id in Enum.GetValues(typeof(HotkeyId)))
|
||||
{
|
||||
var chord = bindings != null && bindings.TryGetValue(id, out var custom)
|
||||
? custom
|
||||
: Defaults.TryGetValue(id, out var def) ? def : null;
|
||||
|
||||
if (chord == null) continue; // unbound action
|
||||
|
||||
if (_registrar.Register(_hwnd, id, chord.Modifiers, chord.VirtualKey))
|
||||
_registered.Add(id);
|
||||
else
|
||||
AppLog.Write($"Hotkey: {id} ({chord.ToDisplayString()}) not registered — likely owned by another app");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnWindowClosed(object? sender, EventArgs e) => Detach();
|
||||
|
||||
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
|
||||
@@ -110,7 +141,7 @@ public sealed class GlobalHotkeyManager
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
private void Detach()
|
||||
public void Detach()
|
||||
{
|
||||
if (_hwnd != IntPtr.Zero)
|
||||
foreach (var id in _registered)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// A modifier+key chord (e.g. Ctrl+F10, Alt+F1, F5).
|
||||
/// Stored in the Settings table as "modifiers+vk" (both uint, decimal).
|
||||
/// </summary>
|
||||
public sealed record HotkeyChord(uint Modifiers, uint VirtualKey)
|
||||
{
|
||||
public const uint MOD_ALT = 0x0001;
|
||||
public const uint MOD_CONTROL = 0x0002;
|
||||
public const uint MOD_SHIFT = 0x0004;
|
||||
public const uint MOD_WIN = 0x0008;
|
||||
|
||||
private static readonly Dictionary<uint, string> ModifierDisplay = new()
|
||||
{
|
||||
[MOD_CONTROL] = "Ctrl",
|
||||
[MOD_ALT] = "Alt",
|
||||
[MOD_SHIFT] = "Shift",
|
||||
[MOD_WIN] = "Win",
|
||||
};
|
||||
|
||||
/// <summary>Storage format: "modifiers+vk" (both decimal uint).</summary>
|
||||
public override string ToString() => $"{Modifiers}+{VirtualKey}";
|
||||
|
||||
/// <summary>Parse from storage format ("modifiers+vk").</summary>
|
||||
public static HotkeyChord ParseStorage(string s)
|
||||
{
|
||||
var parts = s.Split('+');
|
||||
if (parts.Length != 2 ||
|
||||
!uint.TryParse(parts[0], out var mod) ||
|
||||
!uint.TryParse(parts[1], out var vk))
|
||||
throw new FormatException($"Invalid hotkey storage format: {s}");
|
||||
return new HotkeyChord(mod, vk);
|
||||
}
|
||||
|
||||
/// <summary>Build from WPF Key + ModifierKeys (used during key capture).</summary>
|
||||
public static HotkeyChord FromWpf(Key key, ModifierKeys modifiers)
|
||||
{
|
||||
uint vk = (uint)KeyInterop.VirtualKeyFromKey(key);
|
||||
uint mod = 0;
|
||||
if (modifiers.HasFlag(ModifierKeys.Control)) mod |= MOD_CONTROL;
|
||||
if (modifiers.HasFlag(ModifierKeys.Alt)) mod |= MOD_ALT;
|
||||
if (modifiers.HasFlag(ModifierKeys.Shift)) mod |= MOD_SHIFT;
|
||||
if (modifiers.HasFlag(ModifierKeys.Windows)) mod |= MOD_WIN;
|
||||
return new HotkeyChord(mod, vk);
|
||||
}
|
||||
|
||||
/// <summary>Human-readable display string: "Ctrl+F10", "Alt+F1", "F5".</summary>
|
||||
public string ToDisplayString()
|
||||
{
|
||||
var name = GetKeyName(VirtualKey);
|
||||
if (Modifiers == 0) return name;
|
||||
|
||||
var sb = new System.Text.StringBuilder();
|
||||
if ((Modifiers & MOD_CONTROL) != 0) { sb.Append("Ctrl"); sb.Append('+'); }
|
||||
if ((Modifiers & MOD_ALT) != 0) { sb.Append("Alt"); sb.Append('+'); }
|
||||
if ((Modifiers & MOD_SHIFT) != 0) { sb.Append("Shift"); sb.Append('+'); }
|
||||
if ((Modifiers & MOD_WIN) != 0) { sb.Append("Win"); sb.Append('+'); }
|
||||
sb.Append(name);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Human-readable display string for a binding that may be null (unbound).</summary>
|
||||
public static string DisplayString(HotkeyChord? chord) =>
|
||||
chord?.ToDisplayString() ?? "None";
|
||||
|
||||
private static string GetKeyName(uint vk) =>
|
||||
KeyNames.TryGetValue(vk, out var name) ? name : $"VK 0x{vk:X2}";
|
||||
|
||||
private static readonly Dictionary<uint, string> KeyNames = new()
|
||||
{
|
||||
// Letters A-Z (0x41-0x5A)
|
||||
[0x41]="A",[0x42]="B",[0x43]="C",[0x44]="D",[0x45]="E",
|
||||
[0x46]="F",[0x47]="G",[0x48]="H",[0x49]="I",[0x4A]="J",
|
||||
[0x4B]="K",[0x4C]="L",[0x4D]="M",[0x4E]="N",[0x4F]="O",
|
||||
[0x50]="P",[0x51]="Q",[0x52]="R",[0x53]="S",[0x54]="T",
|
||||
[0x55]="U",[0x56]="V",[0x57]="W",[0x58]="X",[0x59]="Y",
|
||||
[0x5A]="Z",
|
||||
// Digits 0-9 (0x30-0x39)
|
||||
[0x30]="0",[0x31]="1",[0x32]="2",[0x33]="3",[0x34]="4",
|
||||
[0x35]="5",[0x36]="6",[0x37]="7",[0x38]="8",[0x39]="9",
|
||||
// Function keys F1-F24 (0x70-0x87)
|
||||
[0x70]="F1",[0x71]="F2",[0x72]="F3",[0x73]="F4",[0x74]="F5",
|
||||
[0x75]="F6",[0x76]="F7",[0x77]="F8",[0x78]="F9",[0x79]="F10",
|
||||
[0x7A]="F11",[0x7B]="F12",[0x7C]="F13",[0x7D]="F14",
|
||||
[0x7E]="F15",[0x7F]="F16",[0x80]="F17",[0x81]="F18",
|
||||
[0x82]="F19",[0x83]="F20",[0x84]="F21",[0x85]="F22",
|
||||
[0x86]="F23",[0x87]="F24",
|
||||
// Navigation
|
||||
[0x08]="Bksp",[0x09]="Tab",[0x0D]="Enter",[0x13]="Pause",
|
||||
[0x14]="CapsLock",[0x1B]="Esc",[0x20]="Space",[0x21]="PgUp",
|
||||
[0x22]="PgDn",[0x23]="End",[0x24]="Home",[0x25]="←",
|
||||
[0x26]="↑",[0x27]="→",[0x28]="↓",[0x2C]="PrintScreen",
|
||||
[0x2D]="Insert",[0x2E]="Delete",
|
||||
// Numpad
|
||||
[0x60]="Num0",[0x61]="Num1",[0x62]="Num2",[0x63]="Num3",
|
||||
[0x64]="Num4",[0x65]="Num5",[0x66]="Num6",[0x67]="Num7",
|
||||
[0x68]="Num8",[0x69]="Num9",[0x6A]="*",[0x6B]="+",
|
||||
[0x6C]=".",[0x6D]="-",[0x6E]=".",[0x6F]="/",
|
||||
[0x90]="NumLock",[0x91]="ScrollLock",
|
||||
// Punctuation / symbols
|
||||
[0xBA]=";",[0xBB]="=",[0xBC]=",",[0xBD]="-",[0xBE]=".",
|
||||
[0xBF]="/",[0xC0]="`",[0xDB]="[",[0xDC]="\\",
|
||||
[0xDD]="]",[0xDE]="'",
|
||||
// Media / browser / apps
|
||||
[0xA0]="LShift",[0xA1]="RShift",
|
||||
[0xA2]="LCtrl",[0xA3]="RCtrl",
|
||||
[0xA4]="LAlt",[0xA5]="RAlt",
|
||||
[0xAD]="Mute",[0xAE]="VolDown",[0xAF]="VolUp",
|
||||
[0xB0]="Next",[0xB1]="Prev",[0xB3]="Stop",[0xB5]="Play",
|
||||
[0xE2]="OEM_102",
|
||||
};
|
||||
}
|
||||
@@ -1220,6 +1220,57 @@ public class LayoutStore : IDisposable
|
||||
meta.ScheduledStartTime?.ToString("o") ?? string.Empty);
|
||||
}
|
||||
|
||||
// ─── Hotkey bindings (TASK 20 step 2) ───
|
||||
|
||||
/// <summary>
|
||||
/// Load saved hotkey bindings. Returns only entries that have been explicitly
|
||||
/// saved — missing HotkeyIds are unbound (not in the returned dictionary).
|
||||
/// Caller fills in defaults for any missing entries as needed.
|
||||
/// </summary>
|
||||
public Dictionary<HotkeyId, HotkeyChord> LoadHotkeyBindings()
|
||||
{
|
||||
var result = new Dictionary<HotkeyId, HotkeyChord>();
|
||||
foreach (HotkeyId id in Enum.GetValues(typeof(HotkeyId)))
|
||||
{
|
||||
var raw = GetSetting($"Hotkey.{id}");
|
||||
if (raw == null) continue; // not saved = unbound
|
||||
if (raw == "none") continue; // explicitly unbound
|
||||
try
|
||||
{
|
||||
result[id] = HotkeyChord.ParseStorage(raw);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
// corrupt entry — skip, will fall back to default
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save hotkey bindings. Null entries in the dictionary mean "unbound" (stored
|
||||
/// as "none"). Only the provided entries are saved; previously saved entries
|
||||
/// for HotkeyIds not in the dictionary are deleted.
|
||||
/// </summary>
|
||||
public void SaveHotkeyBindings(Dictionary<HotkeyId, HotkeyChord?> bindings)
|
||||
{
|
||||
// Delete all existing hotkey settings first
|
||||
foreach (HotkeyId id in Enum.GetValues(typeof(HotkeyId)))
|
||||
{
|
||||
using var cmd = _connection.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM Settings WHERE Key = $key;";
|
||||
cmd.Parameters.AddWithValue("$key", $"Hotkey.{id}");
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// Save the new bindings
|
||||
foreach (var (id, chord) in bindings)
|
||||
{
|
||||
var value = chord?.ToString() ?? "none";
|
||||
UpsertSetting($"Hotkey.{id}", value);
|
||||
}
|
||||
}
|
||||
|
||||
public (double Left, double Top, double Width, double Height, string State)? LoadWindowState()
|
||||
{
|
||||
var left = GetSetting("Window.Left");
|
||||
|
||||
Reference in New Issue
Block a user