Skip to content

Commit dc947ca

Browse files
committed
Allow PromptEditor escape fallthrough
1 parent c9870ca commit dc947ca

File tree

8 files changed

+239
-18
lines changed

8 files changed

+239
-18
lines changed

samples/Playground/Program.cs

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,24 @@
11
using XenoAtom.Terminal;
22
using XenoAtom.Terminal.UI;
3+
using XenoAtom.Terminal.UI.Commands;
34
using XenoAtom.Terminal.UI.Controls;
5+
using XenoAtom.Terminal.UI.Input;
46

5-
State<string?> text = new("Type here");
6-
State<bool> exit = new(false);
7+
8+
9+
var promptEditor = new PromptEditor();
10+
promptEditor.EscapeBehavior(PromptEditorEscapeBehavior.CancelCompletionOnly);
11+
12+
promptEditor.AddCommand(new Command()
13+
{
14+
Id = "CodeAlta.Thread.ExpandPrompt.Close",
15+
LabelMarkup = "Close",
16+
DescriptionMarkup = "Close the large prompt editor and keep the current draft.",
17+
Gesture = new KeyGesture(TerminalKey.Escape),
18+
Importance = CommandImportance.Primary,
19+
Execute = _ => Terminal.Title = "Hello",
20+
});
721

822
Terminal.Run(
9-
new VStack(
10-
new TextBox(text),
11-
new HStack(
12-
"Hello this is a long element Hello this is a long element Hello this is a long element Hello this is a long element Hello this is a long element",
13-
"This is another line"
14-
),
15-
new TextBlock(() => $"The text typed is: {text.Value}"),
16-
new Button("Exit").Click(() => exit.Value = true)
17-
),
18-
onUpdate: () => exit.Value ? TerminalLoopResult.StopAndKeepVisual : TerminalLoopResult.Continue);
23+
promptEditor,
24+
onUpdate: () => TerminalLoopResult.Continue);

site/docs/commands.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ Notes:
4141

4242
- For Ctrl shortcuts, prefer `TerminalChar.CtrlX` + `TerminalModifiers.Ctrl` (terminals commonly emit control characters).
4343
- `Gesture` and `Sequence` are mutually exclusive.
44+
- By default, a matched `Gesture` is consumed even if the command is currently hidden or disabled. Set
45+
`ConsumesGestureWhenUnavailable = false` when a command should reserve the key only while it is active.
4446

4547
## Multi-stroke shortcuts (key sequences)
4648

site/docs/controls/prompteditor.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,16 @@ Default behavior:
104104
- `Tab` requests completion (unless `AcceptTab=true`).
105105
- `Esc` cancels completion (or cancels the prompt if no completion UI is active).
106106

107+
If you want to reuse `Esc` for another command when completion is inactive, switch the editor to completion-only escape
108+
handling:
109+
110+
```csharp
111+
new PromptEditor()
112+
.EscapeBehavior(PromptEditorEscapeBehavior.CancelCompletionOnly);
113+
```
114+
115+
With that mode, `Esc` still dismisses active completion UI, but otherwise falls through to other shortcut bindings.
116+
107117
## Prompt prefix
108118

109119
`PromptEditor` renders the prompt prefix in a dedicated left column:

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

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
using System.Linq;
66
using XenoAtom.Terminal;
7+
using XenoAtom.Terminal.UI.Commands;
78
using XenoAtom.Terminal.UI.Controls;
89
using XenoAtom.Terminal.UI.Hosting;
910
using XenoAtom.Terminal.UI.Input;
@@ -140,6 +141,80 @@ static PromptEditorCompletion Complete(in PromptEditorCompletionRequest request)
140141
driver.TickUntil(() => editor.Text == "hello");
141142
}
142143

144+
[TestMethod]
145+
public void PromptEditor_Escape_Cancels_By_Default()
146+
{
147+
var canceled = false;
148+
149+
var editor = new PromptEditor()
150+
.Canceled((_, _) => canceled = true)
151+
.AutoFocus(true);
152+
153+
var root = new VStack { editor };
154+
155+
using var driver = new TerminalAppTestDriver(root, TerminalHostKind.Fullscreen, new TerminalSize(40, 6));
156+
driver.Tick();
157+
158+
driver.Backend.PushEvent(new TerminalKeyEvent { Key = TerminalKey.Escape });
159+
driver.TickUntil(() => canceled);
160+
}
161+
162+
[TestMethod]
163+
public void PromptEditor_Can_Reserve_Escape_Only_While_Completion_Is_Active()
164+
{
165+
static PromptEditorCompletion Complete(in PromptEditorCompletionRequest request)
166+
=> new(
167+
Handled: true,
168+
Candidates: ["hello", "help"],
169+
ReplaceStart: 0,
170+
ReplaceLength: request.CaretIndex);
171+
172+
var canceled = false;
173+
var customEscapeCount = 0;
174+
175+
var editor = new PromptEditor()
176+
.EscapeBehavior(PromptEditorEscapeBehavior.CancelCompletionOnly)
177+
.CompletionPresentation(PromptEditorCompletionPresentation.InlineCycle)
178+
.CompletionHandler(Complete)
179+
.Canceled((_, _) => canceled = true)
180+
.AutoFocus(true);
181+
182+
editor.AddCommand(new Command
183+
{
184+
Id = "Custom.Close",
185+
LabelMarkup = "Close",
186+
Gesture = new KeyGesture(TerminalKey.Escape),
187+
Execute = _ => customEscapeCount++,
188+
});
189+
190+
var root = new VStack { editor };
191+
192+
using var driver = new TerminalAppTestDriver(root, TerminalHostKind.Fullscreen, new TerminalSize(40, 6));
193+
driver.Tick();
194+
195+
driver.Backend.PushEvent(new TerminalTextEvent { Text = "h" });
196+
driver.TickUntil(() => editor.Text == "h");
197+
198+
driver.Backend.PushEvent(new TerminalKeyEvent { Key = TerminalKey.Escape });
199+
driver.TickUntil(() => customEscapeCount == 1);
200+
201+
Assert.IsFalse(canceled);
202+
203+
driver.Backend.PushEvent(new TerminalKeyEvent { Key = TerminalKey.Tab });
204+
driver.TickUntil(() => editor.Text == "hello");
205+
206+
driver.Backend.PushEvent(new TerminalKeyEvent { Key = TerminalKey.Escape });
207+
driver.Tick();
208+
209+
Assert.AreEqual(1, customEscapeCount);
210+
Assert.IsFalse(canceled);
211+
212+
driver.Backend.PushEvent(new TerminalKeyEvent { Key = TerminalKey.Tab });
213+
driver.Tick();
214+
215+
Assert.AreEqual("hello", editor.Text);
216+
}
217+
143218
[TestMethod]
144219
public void PromptEditor_Uses_Default_Command_Config_When_Config_Is_Null()
145220
{

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

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,38 @@ public void CommandSequence_Throws_When_Prefix_Conflicts_With_Standalone_Command
102102
});
103103
}
104104

105+
[TestMethod]
106+
public void Disabled_Command_Consumes_Gesture_By_Default()
107+
{
108+
var probe = new FallbackGestureProbe(allowFallthrough: false);
109+
var root = new VStack { probe };
110+
111+
using var driver = new TerminalAppTestDriver(root, TerminalHostKind.Fullscreen, new TerminalSize(40, 10));
112+
driver.Tick();
113+
114+
driver.Backend.PushEvent(new TerminalKeyEvent { Key = TerminalKey.Escape });
115+
driver.Tick();
116+
117+
Assert.AreEqual(0, probe.PrimaryCount);
118+
Assert.AreEqual(0, probe.FallbackCount);
119+
}
120+
121+
[TestMethod]
122+
public void Disabled_Command_Can_Allow_Gesture_Fallthrough()
123+
{
124+
var probe = new FallbackGestureProbe(allowFallthrough: true);
125+
var root = new VStack { probe };
126+
127+
using var driver = new TerminalAppTestDriver(root, TerminalHostKind.Fullscreen, new TerminalSize(40, 10));
128+
driver.Tick();
129+
130+
driver.Backend.PushEvent(new TerminalKeyEvent { Key = TerminalKey.Escape });
131+
driver.Tick();
132+
133+
Assert.AreEqual(0, probe.PrimaryCount);
134+
Assert.AreEqual(1, probe.FallbackCount);
135+
}
136+
105137
private sealed class EmptyProbe : Visual
106138
{
107139
protected override SizeHints MeasureCore(in LayoutConstraints constraints) => SizeHints.Fixed(constraints.Clamp(new Size(1, 1)));
@@ -137,4 +169,41 @@ protected override void RenderOverride(CellBuffer buffer)
137169
buffer.WriteText(Bounds.X, Bounds.Y, $"Count:{Count}".AsSpan(), Style.None);
138170
}
139171
}
172+
173+
private sealed class FallbackGestureProbe : Visual
174+
{
175+
public int PrimaryCount { get; private set; }
176+
177+
public int FallbackCount { get; private set; }
178+
179+
public FallbackGestureProbe(bool allowFallthrough)
180+
{
181+
Focusable = true;
182+
183+
AddCommand(new Command
184+
{
185+
Id = "primary",
186+
LabelMarkup = "Primary",
187+
Gesture = new XenoAtom.Terminal.UI.Input.KeyGesture(TerminalKey.Escape),
188+
CanExecute = _ => false,
189+
ConsumesGestureWhenUnavailable = allowFallthrough ? false : true,
190+
Execute = _ => PrimaryCount++,
191+
});
192+
193+
AddCommand(new Command
194+
{
195+
Id = "fallback",
196+
LabelMarkup = "Fallback",
197+
Gesture = new XenoAtom.Terminal.UI.Input.KeyGesture(TerminalKey.Escape),
198+
Execute = _ => FallbackCount++,
199+
});
200+
}
201+
202+
protected override SizeHints MeasureCore(in LayoutConstraints constraints) => SizeHints.Fixed(constraints.Clamp(new Size(10, 1)));
203+
204+
protected override void RenderOverride(CellBuffer buffer)
205+
{
206+
buffer.WriteText(Bounds.X, Bounds.Y, $"P:{PrimaryCount} F:{FallbackCount}".AsSpan(), Style.None);
207+
}
208+
}
140209
}

src/XenoAtom.Terminal.UI/Commands/Command.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,14 @@ public sealed class Command
8888
/// </summary>
8989
public Func<Visual, bool>? IsVisible { get; init; }
9090

91+
/// <summary>
92+
/// Gets a value indicating whether a matching gesture is treated as handled when the command is hidden or cannot execute.
93+
/// </summary>
94+
/// <remarks>
95+
/// When set to <see langword="false"/>, gesture routing continues to the next matching command instead of consuming the key.
96+
/// </remarks>
97+
public bool ConsumesGestureWhenUnavailable { get; init; } = true;
98+
9199
/// <summary>
92100
/// Returns <see langword="true"/> if the command is visible for the specified <paramref name="target"/>.
93101
/// </summary>

src/XenoAtom.Terminal.UI/Controls/PromptEditor.cs

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,22 @@ public enum PromptEditorEnterMode
3434
EnterInsertsNewLine = 1,
3535
}
3636

37+
/// <summary>
38+
/// Specifies how <see cref="TerminalKey.Escape"/> is interpreted by <see cref="PromptEditor"/>.
39+
/// </summary>
40+
public enum PromptEditorEscapeBehavior
41+
{
42+
/// <summary>
43+
/// Escape cancels completion when active; otherwise it raises <see cref="PromptEditor.CanceledEvent"/>.
44+
/// </summary>
45+
CancelPromptOrCompletion = 0,
46+
47+
/// <summary>
48+
/// Escape cancels completion when active and otherwise falls through to other bindings.
49+
/// </summary>
50+
CancelCompletionOnly = 1,
51+
}
52+
3753
/// <summary>
3854
/// Specifies the completion UI mode used by <see cref="PromptEditor"/>.
3955
/// </summary>
@@ -314,6 +330,9 @@ private static Command CreateCancelCommand(PromptEditorCommandConfig config)
314330
Gesture = config.Gesture,
315331
Importance = CommandImportance.Secondary,
316332
Presentation = CommandPresentation.CommandBar,
333+
IsVisible = static v => ((PromptEditor)v).IsCancelCommandVisible,
334+
CanExecute = static v => ((PromptEditor)v).CanExecuteCancelCommand,
335+
ConsumesGestureWhenUnavailable = false,
317336
Execute = static v => ((PromptEditor)v).Cancel(),
318337
};
319338
}
@@ -430,6 +449,12 @@ private static Command CreateHistoryNextCommand(PromptEditorCommandConfig config
430449
[Bindable]
431450
public partial PromptEditorEnterMode EnterMode { get; set; }
432451

452+
/// <summary>
453+
/// Gets or sets how <see cref="TerminalKey.Escape"/> is interpreted.
454+
/// </summary>
455+
[Bindable]
456+
public partial PromptEditorEscapeBehavior EscapeBehavior { get; set; }
457+
433458
/// <summary>
434459
/// Gets or sets a value indicating whether ghost completion is rendered when available.
435460
/// </summary>
@@ -515,6 +540,12 @@ protected virtual void OnCanceled(PromptEditorCanceledEventArgs e) { }
515540
/// <inheritdoc />
516541
protected override bool ShowPlaceholderWhenUnfocusedOnly => false;
517542

543+
private bool HasActiveCompletion => _completionActive || _completionPopup is not null;
544+
545+
private bool CanExecuteCancelCommand => EscapeBehavior == PromptEditorEscapeBehavior.CancelPromptOrCompletion || HasActiveCompletion;
546+
547+
private bool IsCancelCommandVisible => EscapeBehavior == PromptEditorEscapeBehavior.CancelPromptOrCompletion || HasActiveCompletion;
548+
518549
/// <summary>
519550
/// Accepts the current text and raises <see cref="AcceptedEvent"/>.
520551
/// </summary>
@@ -556,7 +587,8 @@ public void InsertNewLine()
556587
/// <inheritdoc />
557588
protected override void OnKeyDown(KeyEventArgs e)
558589
{
559-
if (_completionActive && e.Key != TerminalKey.Tab)
590+
var hadActiveCompletion = HasActiveCompletion;
591+
if (hadActiveCompletion && e.Key != TerminalKey.Tab)
560592
{
561593
CancelCompletion();
562594
}
@@ -575,9 +607,18 @@ protected override void OnKeyDown(KeyEventArgs e)
575607

576608
if (e.Key == TerminalKey.Escape)
577609
{
578-
Cancel();
579-
e.Handled = true;
580-
return;
610+
if (hadActiveCompletion)
611+
{
612+
e.Handled = true;
613+
return;
614+
}
615+
616+
if (EscapeBehavior == PromptEditorEscapeBehavior.CancelPromptOrCompletion)
617+
{
618+
Cancel();
619+
e.Handled = true;
620+
return;
621+
}
581622
}
582623

583624
base.OnKeyDown(e);

src/XenoAtom.Terminal.UI/TerminalApp.cs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2069,7 +2069,12 @@ private bool TryExecuteGestureCommand(TerminalKeyEvent keyEvent, bool allowGloba
20692069

20702070
if (!cmd.IsVisibleFor(v) || !cmd.CanExecuteFor(v))
20712071
{
2072-
return true; // gesture matched but is disabled/hidden in this context; treat as handled.
2072+
if (cmd.ConsumesGestureWhenUnavailable)
2073+
{
2074+
return true; // gesture matched but is disabled/hidden in this context; treat as handled.
2075+
}
2076+
2077+
continue;
20732078
}
20742079

20752080
cmd.Execute(v);
@@ -2101,7 +2106,12 @@ private bool TryExecuteGestureCommand(TerminalKeyEvent keyEvent, bool allowGloba
21012106

21022107
if (!cmd.IsVisibleFor(globalTarget) || !cmd.CanExecuteFor(globalTarget))
21032108
{
2104-
return true;
2109+
if (cmd.ConsumesGestureWhenUnavailable)
2110+
{
2111+
return true;
2112+
}
2113+
2114+
continue;
21052115
}
21062116

21072117
cmd.Execute(globalTarget);

0 commit comments

Comments
 (0)