TASK 20 step 2: hotkey config UI + modifier chord persistence + conflict detection

This commit is contained in:
2026-08-26 06:48:14 -07:00
parent 89ab83be2e
commit 3a1484b3c5
12 changed files with 770 additions and 42 deletions
+53 -22
View File
@@ -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)