Skip to content

Commit 43098f3

Browse files
committed
feat(input): let global shortcut handlers decline so the key reaches the focused window
RegisterGlobalShortcut handlers could only consume the key — TryHandleGlobalShortcut always returned true, and it runs before the active window is routed to, so a shortcut could never let a key fall through to the focused window. Add an opt-in Func<bool> overload: return true to consume, false to decline and let the key continue down the normal pipeline (window cycle, exit key, active window). The backing store is now Func<bool>; the existing Action overload wraps to always-consume, preserving current behavior exactly. InputCoordinator already honors the return value, so no change there. - Tests: declining handler routes the key to the focused control; consuming handler suppresses it; the legacy Action overload still consumes.
1 parent ad1d4a8 commit 43098f3

2 files changed

Lines changed: 107 additions & 12 deletions

File tree

SharpConsoleUI.Tests/InputHandling/ShortcutsTests.cs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,91 @@ public void End_ScrollsToBottom()
390390
Assert.True(window.ScrollOffset > 30);
391391
}
392392

393+
[Fact]
394+
public void GlobalShortcut_FuncReturningFalse_DeclinesAndKeyReachesFocusedControl()
395+
{
396+
var system = TestWindowSystemBuilder.CreateTestSystem();
397+
var window = new Window(system);
398+
var button = new ButtonControl { Text = "Submit" };
399+
window.AddControl(button);
400+
system.WindowStateService.AddWindow(window);
401+
system.WindowStateService.SetActiveWindow(window);
402+
window.FocusManager.SetFocus(button, FocusReason.Programmatic);
403+
404+
bool clicked = false;
405+
button.Click += (s, e) => clicked = true;
406+
407+
// Declining shortcut on Enter: handler runs but returns false → key keeps routing.
408+
bool handlerRan = false;
409+
system.RegisterGlobalShortcut(ConsoleModifiers.None, ConsoleKey.Enter, () =>
410+
{
411+
handlerRan = true;
412+
return false; // decline
413+
});
414+
415+
var enterKey = new ConsoleKeyInfo('\r', ConsoleKey.Enter, false, false, false);
416+
system.InputStateService.EnqueueKey(enterKey);
417+
system.Input.ProcessInput();
418+
419+
Assert.True(handlerRan, "Declining handler should have run");
420+
Assert.True(clicked, "Declined key should still reach the focused control");
421+
}
422+
423+
[Fact]
424+
public void GlobalShortcut_FuncReturningTrue_ConsumesAndKeyDoesNotReachFocusedControl()
425+
{
426+
var system = TestWindowSystemBuilder.CreateTestSystem();
427+
var window = new Window(system);
428+
var button = new ButtonControl { Text = "Submit" };
429+
window.AddControl(button);
430+
system.WindowStateService.AddWindow(window);
431+
system.WindowStateService.SetActiveWindow(window);
432+
window.FocusManager.SetFocus(button, FocusReason.Programmatic);
433+
434+
bool clicked = false;
435+
button.Click += (s, e) => clicked = true;
436+
437+
bool handlerRan = false;
438+
system.RegisterGlobalShortcut(ConsoleModifiers.None, ConsoleKey.Enter, () =>
439+
{
440+
handlerRan = true;
441+
return true; // consume
442+
});
443+
444+
var enterKey = new ConsoleKeyInfo('\r', ConsoleKey.Enter, false, false, false);
445+
system.InputStateService.EnqueueKey(enterKey);
446+
system.Input.ProcessInput();
447+
448+
Assert.True(handlerRan, "Consuming handler should have run");
449+
Assert.False(clicked, "Consumed key must not reach the focused control");
450+
}
451+
452+
[Fact]
453+
public void GlobalShortcut_ActionOverload_StillConsumes()
454+
{
455+
var system = TestWindowSystemBuilder.CreateTestSystem();
456+
var window = new Window(system);
457+
var button = new ButtonControl { Text = "Submit" };
458+
window.AddControl(button);
459+
system.WindowStateService.AddWindow(window);
460+
system.WindowStateService.SetActiveWindow(window);
461+
window.FocusManager.SetFocus(button, FocusReason.Programmatic);
462+
463+
bool clicked = false;
464+
button.Click += (s, e) => clicked = true;
465+
466+
// Back-compat: the Action overload always consumes (unchanged behavior).
467+
bool handlerRan = false;
468+
system.RegisterGlobalShortcut(ConsoleModifiers.None, ConsoleKey.Enter, () => { handlerRan = true; });
469+
470+
var enterKey = new ConsoleKeyInfo('\r', ConsoleKey.Enter, false, false, false);
471+
system.InputStateService.EnqueueKey(enterKey);
472+
system.Input.ProcessInput();
473+
474+
Assert.True(handlerRan, "Action handler should have run");
475+
Assert.False(clicked, "Action shortcut must consume the key (key does not reach the control)");
476+
}
477+
393478
// TODO: MultilineEditControl doesn't have SelectAll() method yet
394479
//[Fact]
395480
//public void CtrlC_OnTextBox_CopiesSelection()

SharpConsoleUI/ConsoleWindowSystem.cs

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ public class ConsoleWindowSystem
103103
private Diagnostics.RenderingDiagnostics? _renderingDiagnostics;
104104

105105
// Global keyboard shortcuts registered by the application
106-
private readonly Dictionary<(ConsoleModifiers Modifiers, ConsoleKey Key), Action> _globalShortcuts = new();
106+
private readonly Dictionary<(ConsoleModifiers Modifiers, ConsoleKey Key), Func<bool>> _globalShortcuts = new();
107107

108108
// Main loop watchdog — detects stalled frames and provides emergency Ctrl+Q exit
109109
private readonly MainLoopWatchdog _watchdog;
@@ -1409,27 +1409,37 @@ public void RegisterSettingsPage(string name, string? icon = null,
14091409

14101410
/// <summary>
14111411
/// Registers a global keyboard shortcut that is handled before routing to windows.
1412+
/// The key is always consumed (it does not continue to the focused window). To let the
1413+
/// handler decide whether the key is consumed, use the <see cref="Func{TResult}"/> overload.
14121414
/// </summary>
14131415
/// <param name="modifiers">The modifier keys (e.g. Control, Alt, Shift).</param>
14141416
/// <param name="key">The key to bind.</param>
14151417
/// <param name="action">The action to invoke when the shortcut is pressed.</param>
14161418
public void RegisterGlobalShortcut(ConsoleModifiers modifiers, ConsoleKey key, Action action)
1417-
{
1418-
_globalShortcuts[(modifiers, key)] = action;
1419-
}
1419+
=> _globalShortcuts[(modifiers, key)] = () => { action(); return true; };
1420+
1421+
/// <summary>
1422+
/// Registers a global keyboard shortcut whose handler decides whether the key is consumed.
1423+
/// Return <c>true</c> to consume the key (stop routing); return <c>false</c> to decline,
1424+
/// letting the key continue down the normal pipeline (window cycle, exit key, active window).
1425+
/// This enables conditional shortcuts — e.g. a global Ctrl+Q that reaches a focused control
1426+
/// but acts globally when nothing relevant is focused.
1427+
/// </summary>
1428+
/// <param name="modifiers">The modifier keys (e.g. Control, Alt, Shift).</param>
1429+
/// <param name="key">The key to bind.</param>
1430+
/// <param name="action">
1431+
/// The handler to invoke; returns <c>true</c> to consume the key, <c>false</c> to decline.
1432+
/// </param>
1433+
public void RegisterGlobalShortcut(ConsoleModifiers modifiers, ConsoleKey key, Func<bool> action)
1434+
=> _globalShortcuts[(modifiers, key)] = action;
14201435

14211436
/// <summary>
14221437
/// Tries to execute a registered global shortcut for the given key info.
1438+
/// Returns the handler's result (<c>true</c> = consumed), or <c>false</c> when no shortcut
1439+
/// is registered for the key or the handler declined.
14231440
/// </summary>
14241441
internal bool TryHandleGlobalShortcut(ConsoleKeyInfo keyInfo)
1425-
{
1426-
if (_globalShortcuts.TryGetValue((keyInfo.Modifiers, keyInfo.Key), out var action))
1427-
{
1428-
action();
1429-
return true;
1430-
}
1431-
return false;
1432-
}
1442+
=> _globalShortcuts.TryGetValue((keyInfo.Modifiers, keyInfo.Key), out var action) && action();
14331443

14341444
#endregion
14351445

0 commit comments

Comments
 (0)