Skip to content

Commit 50f2a5b

Browse files
committed
Fix runtime replacement of quit command
1 parent ef0efa2 commit 50f2a5b

File tree

4 files changed

+167
-10
lines changed

4 files changed

+167
-10
lines changed

site/docs/commands.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ XenoAtom.Terminal.UI provides a lightweight **command system** to make keyboard
88

99
Commands are **retained-mode**, registered on visuals (local) or on the `TerminalApp` (global), and routed using the same focus → parent traversal as other keyboard handling.
1010

11+
`TerminalApp` registers a default global quit command (`TerminalApp.DefaultQuitCommandId`, value `TerminalApp.Quit`). Replacing or removing that command after the app starts also updates the live app-exit shortcut, not just command discovery surfaces.
12+
1113
## What is a command?
1214

1315
A `Command` is an action with:

src/XenoAtom.Terminal.UI.Tests/CommandBarRenderingTests.cs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,34 @@ public void CommandBar_MultiLine_Wraps_Commands_To_Additional_Rows()
8282
Assert.IsTrue(alphaRow != betaRow || betaRow != gammaRow, "Expected wrapped commands to span multiple rows.");
8383
}
8484

85+
[TestMethod]
86+
public void CommandBar_Refreshes_When_Global_Command_Is_Replaced_After_Run_Starts()
87+
{
88+
var probe = new CommandProbe();
89+
var bar = new CommandBar();
90+
var layout = new DockLayout { Content = probe, Bottom = bar };
91+
92+
using var driver = new TerminalAppTestDriver(layout, TerminalHostKind.Fullscreen, new TerminalSize(60, 6));
93+
driver.App.Focus(probe);
94+
driver.Tick();
95+
96+
driver.App.AddGlobalCommand(new Command
97+
{
98+
Id = TerminalApp.DefaultQuitCommandId,
99+
LabelMarkup = "Leave",
100+
Gesture = new XenoAtom.Terminal.UI.Input.KeyGesture(TerminalKey.F4),
101+
Execute = _ => { },
102+
});
103+
104+
driver.Tick();
105+
106+
var rendered = string.Join('\n', GetScreenLines(driver, 60, 6));
107+
StringAssert.Contains(rendered, "F4");
108+
StringAssert.Contains(rendered, "Leave");
109+
Assert.IsFalse(rendered.Contains("Ctrl+Q", StringComparison.Ordinal), "The command bar should stop showing the original quit gesture after replacement.");
110+
Assert.IsFalse(rendered.Contains("Quit", StringComparison.Ordinal), "The command bar should stop showing the original quit label after replacement.");
111+
}
112+
85113
private static string[] GetScreenLines(TerminalAppTestDriver driver, int width, int height)
86114
{
87115
var screen = new AnsiTestScreen(width, height);

src/XenoAtom.Terminal.UI.Tests/UiCommandShortcutTests.cs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the BSD-Clause 2 license.
33
// See license.txt file in the project root for full license information.
44

5+
using System.Reflection;
56
using XenoAtom.Terminal.UI.Commands;
67
using XenoAtom.Terminal.UI.Geometry;
78
using XenoAtom.Terminal.UI.Hosting;
@@ -134,6 +135,79 @@ public void Disabled_Command_Can_Allow_Gesture_Fallthrough()
134135
Assert.AreEqual(1, probe.FallbackCount);
135136
}
136137

138+
[TestMethod]
139+
public void Replacing_Default_Quit_Command_Updates_Runtime_Gesture_Handling()
140+
{
141+
var root = new EmptyProbe();
142+
var invoked = false;
143+
144+
using var driver = new TerminalAppTestDriver(root, TerminalHostKind.Fullscreen, new TerminalSize(40, 10));
145+
driver.Tick();
146+
147+
driver.App.AddGlobalCommand(new Command
148+
{
149+
Id = TerminalApp.DefaultQuitCommandId,
150+
LabelMarkup = "Leave",
151+
Gesture = new XenoAtom.Terminal.UI.Input.KeyGesture(TerminalKey.F4),
152+
Execute = _ => invoked = true,
153+
});
154+
155+
driver.Tick();
156+
157+
driver.Backend.PushEvent(new TerminalKeyEvent { Key = TerminalKey.Unknown, Char = TerminalChar.CtrlQ, Modifiers = TerminalModifiers.Ctrl });
158+
driver.Tick();
159+
160+
Assert.IsFalse(IsStopRequested(driver.App), "Replacing TerminalApp.Quit should disable the original built-in exit gesture.");
161+
Assert.IsFalse(invoked, "The replacement exit command should not run for the old gesture.");
162+
163+
driver.Backend.PushEvent(new TerminalKeyEvent { Key = TerminalKey.F4 });
164+
driver.Tick();
165+
166+
Assert.IsTrue(invoked, "The replacement exit command should run for its new gesture.");
167+
}
168+
169+
[TestMethod]
170+
public void Removing_Then_Readding_Default_Quit_Command_Updates_Runtime_Gesture_Handling()
171+
{
172+
var root = new EmptyProbe();
173+
var invoked = false;
174+
175+
using var driver = new TerminalAppTestDriver(root, TerminalHostKind.Fullscreen, new TerminalSize(40, 10));
176+
driver.Tick();
177+
178+
Assert.IsTrue(driver.App.RemoveGlobalCommand(TerminalApp.DefaultQuitCommandId), "Expected the default quit command to be registered.");
179+
180+
driver.App.AddGlobalCommand(new Command
181+
{
182+
Id = TerminalApp.DefaultQuitCommandId,
183+
LabelMarkup = "Leave",
184+
Gesture = new XenoAtom.Terminal.UI.Input.KeyGesture(TerminalKey.F4),
185+
Execute = _ => invoked = true,
186+
});
187+
188+
driver.Tick();
189+
190+
driver.Backend.PushEvent(new TerminalKeyEvent { Key = TerminalKey.Unknown, Char = TerminalChar.CtrlQ, Modifiers = TerminalModifiers.Ctrl });
191+
driver.Tick();
192+
193+
Assert.IsFalse(IsStopRequested(driver.App), "Removing and re-adding TerminalApp.Quit should disable the original built-in exit gesture.");
194+
Assert.IsFalse(invoked, "The re-registered quit command should not run for the old gesture.");
195+
196+
driver.Backend.PushEvent(new TerminalKeyEvent { Key = TerminalKey.F4 });
197+
driver.Tick();
198+
199+
Assert.IsTrue(invoked, "The re-registered quit command should run for its new gesture.");
200+
}
201+
202+
private static bool IsStopRequested(TerminalApp app)
203+
{
204+
var ctsField = typeof(TerminalApp).GetField("_cts", BindingFlags.Instance | BindingFlags.NonPublic);
205+
Assert.IsNotNull(ctsField, "Expected TerminalApp to expose its cancellation token source field for tests.");
206+
var cts = (CancellationTokenSource?)ctsField.GetValue(app);
207+
Assert.IsNotNull(cts, "Expected TerminalApp to initialize its cancellation token source.");
208+
return cts.IsCancellationRequested;
209+
}
210+
137211
private sealed class EmptyProbe : Visual
138212
{
139213
protected override SizeHints MeasureCore(in LayoutConstraints constraints) => SizeHints.Fixed(constraints.Clamp(new Size(1, 1)));

src/XenoAtom.Terminal.UI/TerminalApp.cs

Lines changed: 63 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@ public sealed partial class TerminalApp : DispatcherObject, IAsyncDisposable, IV
6060
private Func<TerminalRunningContext, ValueTask<TerminalLoopResult>>? _onUpdate;
6161
private TerminalRunningContext? _updateContext;
6262
private readonly AnsiBuilder _updateOutputBuilder = new(initialCapacity: 4096);
63-
private global::XenoAtom.Terminal.UI.Input.KeyGesture _exitGesture;
6463
private bool _inlineRemoveOnEnd;
6564
private Dictionary<string, AnsiStyle>? _previousMarkupStyles;
6665

@@ -133,6 +132,15 @@ private enum SceneRenderMode
133132
/// </summary>
134133
public Func<Rune, bool> WideRuneResolver => _wideRuneResolver;
135134

135+
/// <summary>
136+
/// Gets the command id used by the built-in application quit command.
137+
/// </summary>
138+
/// <remarks>
139+
/// Applications can use this id to replace or re-register the default quit command through
140+
/// <see cref="GlobalCommands"/>, <see cref="AddGlobalCommand(Command)"/>, and <see cref="RemoveGlobalCommand(string)"/>.
141+
/// </remarks>
142+
public static string DefaultQuitCommandId { get; } = "TerminalApp.Quit";
143+
136144
/// <summary>
137145
/// Gets the global commands registered on this application.
138146
/// </summary>
@@ -284,14 +292,14 @@ internal TerminalApp(Visual root, TerminalInstance? terminal, TerminalAppOptions
284292
_inlineHost = new InlineInteractiveHost(_terminal);
285293
}
286294

287-
_exitGesture = _options.ExitGesture ?? GetDefaultExitGesture(_options.HostKind);
295+
var exitGesture = _options.ExitGesture ?? GetDefaultExitGesture(_options.HostKind);
288296

289297
AddGlobalCommand(new Command
290298
{
291-
Id = "TerminalApp.Quit",
299+
Id = DefaultQuitCommandId,
292300
LabelMarkup = "Quit",
293301
DescriptionMarkup = "Quit the application.",
294-
Gesture = _exitGesture,
302+
Gesture = exitGesture,
295303
Importance = CommandImportance.Primary,
296304
Presentation = CommandPresentation.CommandBar,
297305
Execute = static v => v.App?.Stop(),
@@ -2570,13 +2578,8 @@ private void HandleTerminalEvent(TerminalEvent ev)
25702578

25712579
var activeModal = FindActiveModalRoot(Root);
25722580

2573-
if (_exitGesture.Matches(keyEvent))
2581+
if (TryHandleAppExitGesture(keyEvent))
25742582
{
2575-
// Allow controls to handle the exit gesture (e.g. close transient popups) before exiting the app.
2576-
if (!DispatchKeyEvent(keyEvent, routeCommands: false))
2577-
{
2578-
_cts.Cancel();
2579-
}
25802583
return;
25812584
}
25822585

@@ -2615,6 +2618,56 @@ private void HandleTerminalEvent(TerminalEvent ev)
26152618
? new global::XenoAtom.Terminal.UI.Input.KeyGesture(TerminalChar.CtrlQ, TerminalModifiers.Ctrl)
26162619
: new global::XenoAtom.Terminal.UI.Input.KeyGesture(TerminalKey.Escape);
26172620

2621+
private bool TryHandleAppExitGesture(TerminalKeyEvent keyEvent)
2622+
{
2623+
if (!TryGetDefaultQuitCommand(out var command))
2624+
{
2625+
return false;
2626+
}
2627+
2628+
if (command.Gesture is not { } gesture || !gesture.Matches(keyEvent))
2629+
{
2630+
return false;
2631+
}
2632+
2633+
// Allow controls to observe the raw gesture first (e.g. close a transient popup) before the app-level
2634+
// quit command runs. The command itself stays replaceable at runtime via GlobalCommands.
2635+
if (DispatchKeyEvent(keyEvent, routeCommands: false))
2636+
{
2637+
return true;
2638+
}
2639+
2640+
EnsureFocusInScope();
2641+
var target = FocusedElement ?? Root;
2642+
if (!command.IsVisibleFor(target) || !command.CanExecuteFor(target))
2643+
{
2644+
return command.ConsumesGestureWhenUnavailable;
2645+
}
2646+
2647+
command.Execute(target);
2648+
return true;
2649+
}
2650+
2651+
private bool TryGetDefaultQuitCommand(out Command command)
2652+
{
2653+
var commands = _globalCommands;
2654+
if (commands is not null)
2655+
{
2656+
for (var i = 0; i < commands.Count; i++)
2657+
{
2658+
var candidate = commands[i];
2659+
if (string.Equals(candidate.Id, DefaultQuitCommandId, StringComparison.Ordinal))
2660+
{
2661+
command = candidate;
2662+
return true;
2663+
}
2664+
}
2665+
}
2666+
2667+
command = null!;
2668+
return false;
2669+
}
2670+
26182671
private void DispatchTextInput(string text)
26192672
{
26202673
EnsureFocusInScope();

0 commit comments

Comments
 (0)