Skip to content

Commit 25bfffa

Browse files
committed
feat: WantsTabKey — editors receive Tab/Shift+Tab in edit mode for indent/dedent
Added IInteractiveControl.WantsTabKey default interface method (returns false). WindowEventDispatcher checks WantsTabKey before intercepting Tab for focus traversal — if the control opts in and ProcessKey returns true, Tab is consumed; otherwise it bubbles to focus traversal as normal. MultilineEditControl: WantsTabKey returns true when IsEditing && !ReadOnly. Tab/Shift+Tab are always consumed in edit mode (indent/dedent). Escape exits edit mode, after which Tab resumes focus traversal. TerminalControl: WantsTabKey always true (terminal emulation needs Tab chars).
1 parent 39921c0 commit 25bfffa

6 files changed

Lines changed: 367 additions & 4 deletions

File tree

Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
// -----------------------------------------------------------------------
2+
// ConsoleEx - A simple console window system for .NET Core
3+
//
4+
// Author: Nikolaos Protopapas
5+
// Email: nikolaos.protopapas@gmail.com
6+
// License: MIT
7+
// -----------------------------------------------------------------------
8+
9+
using SharpConsoleUI;
10+
using SharpConsoleUI.Controls;
11+
using SharpConsoleUI.Tests.Infrastructure;
12+
using Xunit;
13+
using Xunit.Abstractions;
14+
15+
namespace SharpConsoleUI.Tests.FocusManagement;
16+
17+
/// <summary>
18+
/// Tests for IInteractiveControl.WantsTabKey — controls opt in to receive
19+
/// Tab/Shift+Tab instead of having them intercepted for focus traversal.
20+
/// </summary>
21+
public class WantsTabKeyTests
22+
{
23+
private readonly ITestOutputHelper _out;
24+
public WantsTabKeyTests(ITestOutputHelper output) => _out = output;
25+
26+
private static readonly ConsoleKeyInfo TabKey = new('\t', ConsoleKey.Tab, false, false, false);
27+
private static readonly ConsoleKeyInfo ShiftTabKey = new('\t', ConsoleKey.Tab, true, false, false);
28+
29+
private static (ConsoleWindowSystem system, Window window) Setup()
30+
{
31+
var system = TestWindowSystemBuilder.CreateTestSystem(120, 40);
32+
var window = new Window(system) { Width = 100, Height = 30 };
33+
return (system, window);
34+
}
35+
36+
private static void Activate(ConsoleWindowSystem system, Window window)
37+
{
38+
system.AddWindow(window);
39+
system.Render.UpdateDisplay();
40+
system.Render.UpdateDisplay();
41+
}
42+
43+
/// <summary>
44+
/// Sends a key through the full dispatcher pipeline (not SwitchFocus which bypasses WantsTabKey).
45+
/// </summary>
46+
private static void SendKey(Window window, ConsoleKeyInfo key)
47+
{
48+
window.EventDispatcher!.ProcessInput(key);
49+
}
50+
51+
#region Default WantsTabKey behavior
52+
53+
[Fact]
54+
public void DefaultWantsTabKey_IsFalse()
55+
{
56+
var button = new ButtonControl { Text = "Test" };
57+
Assert.False(((IInteractiveControl)button).WantsTabKey);
58+
}
59+
60+
[Fact]
61+
public void ListControl_WantsTabKey_IsFalse()
62+
{
63+
var list = new ListControl(new[] { "A", "B" });
64+
Assert.False(((IInteractiveControl)list).WantsTabKey);
65+
}
66+
67+
#endregion
68+
69+
#region MultilineEditControl WantsTabKey
70+
71+
[Fact]
72+
public void MultilineEdit_ViewMode_WantsTabKeyFalse()
73+
{
74+
var editor = new MultilineEditControl();
75+
Assert.False(editor.IsEditing);
76+
Assert.False(editor.WantsTabKey);
77+
}
78+
79+
[Fact]
80+
public void MultilineEdit_EditMode_WantsTabKeyTrue()
81+
{
82+
var editor = new MultilineEditControl();
83+
editor.IsEditing = true;
84+
Assert.True(editor.WantsTabKey);
85+
}
86+
87+
[Fact]
88+
public void MultilineEdit_EditModeReadOnly_WantsTabKeyFalse()
89+
{
90+
var editor = new MultilineEditControl { ReadOnly = true };
91+
editor.IsEditing = true;
92+
Assert.False(editor.WantsTabKey);
93+
}
94+
95+
#endregion
96+
97+
#region Tab inserts indent in edit mode
98+
99+
[Fact]
100+
public void EditMode_TabInsertsSpaces_FocusDoesNotMove()
101+
{
102+
var (system, window) = Setup();
103+
104+
var btn = new ButtonControl { Text = "Before" };
105+
var editor = new MultilineEditControl();
106+
editor.Content = "hello";
107+
editor.IsEditing = true;
108+
var btn2 = new ButtonControl { Text = "After" };
109+
110+
window.AddControl(btn);
111+
window.AddControl(editor);
112+
window.AddControl(btn2);
113+
Activate(system, window);
114+
115+
// Focus editor
116+
window.FocusManager.SetFocus(editor, FocusReason.Keyboard);
117+
Assert.True(editor.HasFocus);
118+
119+
// Press Tab through dispatcher — should indent, NOT move focus
120+
SendKey(window, TabKey);
121+
122+
// Editor should still have focus (Tab was consumed for indentation)
123+
Assert.True(editor.HasFocus, "Editor should still have focus after Tab in edit mode");
124+
// Content should have spaces inserted
125+
Assert.Contains(" ", editor.Content);
126+
_out.WriteLine($"Content after Tab: '{editor.Content}'");
127+
}
128+
129+
[Fact]
130+
public void EditMode_ShiftTabDedents_FocusDoesNotMove()
131+
{
132+
var (system, window) = Setup();
133+
134+
var btn = new ButtonControl { Text = "Before" };
135+
var editor = new MultilineEditControl();
136+
editor.Content = " hello";
137+
editor.IsEditing = true;
138+
139+
window.AddControl(btn);
140+
window.AddControl(editor);
141+
Activate(system, window);
142+
143+
window.FocusManager.SetFocus(editor, FocusReason.Keyboard);
144+
Assert.True(editor.HasFocus);
145+
146+
// Press Shift+Tab through dispatcher — should dedent
147+
SendKey(window, ShiftTabKey);
148+
149+
Assert.True(editor.HasFocus, "Editor should still have focus after Shift+Tab in edit mode");
150+
Assert.Equal("hello", editor.Content.TrimEnd('\n'));
151+
_out.WriteLine($"Content after Shift+Tab: '{editor.Content}'");
152+
}
153+
154+
#endregion
155+
156+
#region View mode Tab moves focus
157+
158+
[Fact]
159+
public void ViewMode_TabMovesFocus()
160+
{
161+
var (system, window) = Setup();
162+
163+
var btn = new ButtonControl { Text = "Before" };
164+
var editor = new MultilineEditControl();
165+
editor.Content = "hello";
166+
// IsEditing defaults to false (view mode)
167+
var btn2 = new ButtonControl { Text = "After" };
168+
169+
window.AddControl(btn);
170+
window.AddControl(editor);
171+
window.AddControl(btn2);
172+
Activate(system, window);
173+
174+
window.FocusManager.SetFocus(editor, FocusReason.Keyboard);
175+
Assert.True(editor.HasFocus);
176+
Assert.False(editor.IsEditing);
177+
178+
// Tab should move focus to btn2 (view mode, WantsTabKey=false)
179+
SendKey(window, TabKey);
180+
181+
Assert.False(editor.HasFocus, "Editor should lose focus on Tab in view mode");
182+
Assert.True(btn2.HasFocus, "btn2 should gain focus");
183+
}
184+
185+
[Fact]
186+
public void ViewMode_ShiftTabMovesFocusBackward()
187+
{
188+
var (system, window) = Setup();
189+
190+
var btn = new ButtonControl { Text = "Before" };
191+
var editor = new MultilineEditControl();
192+
editor.Content = "hello";
193+
194+
window.AddControl(btn);
195+
window.AddControl(editor);
196+
Activate(system, window);
197+
198+
window.FocusManager.SetFocus(editor, FocusReason.Keyboard);
199+
Assert.True(editor.HasFocus);
200+
201+
// Shift+Tab should move focus backward to btn
202+
SendKey(window, ShiftTabKey);
203+
204+
Assert.False(editor.HasFocus);
205+
Assert.True(btn.HasFocus, "btn should gain focus on Shift+Tab in view mode");
206+
}
207+
208+
#endregion
209+
210+
#region Shift+Tab consumed even when nothing to dedent
211+
212+
[Fact]
213+
public void EditMode_ShiftTabNothingToDedent_StillConsumed()
214+
{
215+
var (system, window) = Setup();
216+
217+
var btn = new ButtonControl { Text = "Before" };
218+
var editor = new MultilineEditControl();
219+
editor.Content = "hello"; // no leading spaces — Shift+Tab has nothing to remove
220+
editor.IsEditing = true;
221+
222+
window.AddControl(btn);
223+
window.AddControl(editor);
224+
Activate(system, window);
225+
226+
window.FocusManager.SetFocus(editor, FocusReason.Keyboard);
227+
Assert.True(editor.HasFocus);
228+
229+
// Shift+Tab with no leading spaces — still consumed in edit mode (no accidental focus escape)
230+
SendKey(window, ShiftTabKey);
231+
232+
Assert.True(editor.HasFocus, "Shift+Tab should be consumed in edit mode even with nothing to dedent");
233+
}
234+
235+
#endregion
236+
237+
#region ReadOnly editor Tab moves focus
238+
239+
[Fact]
240+
public void ReadOnlyEditMode_TabMovesFocus()
241+
{
242+
var (system, window) = Setup();
243+
244+
var btn = new ButtonControl { Text = "Before" };
245+
var editor = new MultilineEditControl { ReadOnly = true };
246+
editor.Content = "hello";
247+
editor.IsEditing = true; // Even in edit mode, readonly Tab doesn't insert
248+
var btn2 = new ButtonControl { Text = "After" };
249+
250+
window.AddControl(btn);
251+
window.AddControl(editor);
252+
window.AddControl(btn2);
253+
Activate(system, window);
254+
255+
window.FocusManager.SetFocus(editor, FocusReason.Keyboard);
256+
Assert.True(editor.HasFocus);
257+
Assert.False(editor.WantsTabKey); // ReadOnly → WantsTabKey=false
258+
259+
SendKey(window, TabKey);
260+
261+
Assert.False(editor.HasFocus, "Readonly editor should lose focus on Tab");
262+
Assert.True(btn2.HasFocus);
263+
}
264+
265+
#endregion
266+
267+
#region Escape exits edit mode, then Tab moves focus
268+
269+
[Fact]
270+
public void EscapeThenTab_ExitsEditModeThenMovesFocus()
271+
{
272+
var (system, window) = Setup();
273+
274+
var btn = new ButtonControl { Text = "Target" };
275+
var editor = new MultilineEditControl { EscapeExitsEditMode = true };
276+
editor.Content = "hello";
277+
editor.IsEditing = true;
278+
279+
window.AddControl(editor);
280+
window.AddControl(btn);
281+
Activate(system, window);
282+
283+
window.FocusManager.SetFocus(editor, FocusReason.Keyboard);
284+
Assert.True(editor.IsEditing);
285+
Assert.True(editor.WantsTabKey);
286+
287+
// Press Escape — exits edit mode
288+
var escKey = new ConsoleKeyInfo('\x1b', ConsoleKey.Escape, false, false, false);
289+
editor.ProcessKey(escKey);
290+
291+
Assert.False(editor.IsEditing, "Escape should exit edit mode");
292+
Assert.False(editor.WantsTabKey, "WantsTabKey should be false after exiting edit mode");
293+
294+
// Now Tab should move focus
295+
SendKey(window, TabKey);
296+
Assert.True(btn.HasFocus, "Tab should move focus after Escape exited edit mode");
297+
}
298+
299+
#endregion
300+
301+
#region Multiple editors — Tab stays within editing editor
302+
303+
[Fact]
304+
public void TwoEditors_TabStaysInEditingOne()
305+
{
306+
var (system, window) = Setup();
307+
308+
var editor1 = new MultilineEditControl { Name = "Editor1" };
309+
editor1.Content = "first";
310+
editor1.IsEditing = true;
311+
312+
var editor2 = new MultilineEditControl { Name = "Editor2" };
313+
editor2.Content = "second";
314+
// editor2 is NOT in edit mode
315+
316+
window.AddControl(editor1);
317+
window.AddControl(editor2);
318+
Activate(system, window);
319+
320+
window.FocusManager.SetFocus(editor1, FocusReason.Keyboard);
321+
Assert.True(editor1.HasFocus);
322+
323+
// Tab should stay in editor1 (edit mode)
324+
SendKey(window, TabKey);
325+
Assert.True(editor1.HasFocus, "Tab should stay in editing editor1");
326+
327+
// Exit edit mode
328+
editor1.IsEditing = false;
329+
330+
// Now Tab should move to editor2
331+
SendKey(window, TabKey);
332+
Assert.True(editor2.HasFocus, "Tab should move to editor2 after exiting edit mode");
333+
}
334+
335+
#endregion
336+
}

SharpConsoleUI/Controls/IInteractiveControl.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,13 @@ public interface IInteractiveControl
2222
/// <param name="key">The key information for the pressed key.</param>
2323
/// <returns>True if the key was handled by this control; otherwise, false.</returns>
2424
bool ProcessKey(ConsoleKeyInfo key);
25+
26+
/// <summary>
27+
/// When <c>true</c>, Tab and Shift+Tab are passed to <see cref="ProcessKey"/> instead
28+
/// of being intercepted for focus traversal. If <see cref="ProcessKey"/> returns
29+
/// <c>false</c>, the key bubbles back to focus traversal as normal.
30+
/// Default: <c>false</c>.
31+
/// </summary>
32+
bool WantsTabKey => false;
2533
}
2634
}

SharpConsoleUI/Controls/MultilineEdit/MultilineEditControl.Keyboard.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ public bool ProcessKey(ConsoleKeyInfo key)
166166
int oldSelEndY;
167167
bool cursorMoved;
168168
bool selectionChanged;
169-
bool keyWasHandled;
169+
bool keyWasHandled = false;
170170

171171
lock (_contentLock)
172172
{
@@ -603,6 +603,7 @@ public bool ProcessKey(ConsoleKeyInfo key)
603603

604604
case ConsoleKey.Tab:
605605
if (_readOnly) break;
606+
keyWasHandled = true; // Always consume Tab/Shift+Tab in edit mode
606607
if (isShiftPressed)
607608
{
608609
// Shift+Tab: dedent
@@ -859,7 +860,7 @@ public bool ProcessKey(ConsoleKeyInfo key)
859860
cursorMoved = (_cursorX != oldCursorX || _cursorY != oldCursorY);
860861
selectionChanged = (_hasSelection != oldHasSelection) ||
861862
(_hasSelection && (_selectionEndX != oldSelEndX || _selectionEndY != oldSelEndY));
862-
keyWasHandled = contentChanged || cursorMoved || selectionChanged;
863+
keyWasHandled = keyWasHandled || contentChanged || cursorMoved || selectionChanged;
863864

864865
} // end lock (_contentLock)
865866

SharpConsoleUI/Controls/MultilineEdit/MultilineEditControl.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,12 @@ public bool HasFocus
319319
get => ComputeHasFocus();
320320
}
321321

322+
/// <summary>
323+
/// When in edit mode and not read-only, Tab/Shift+Tab are handled by the editor
324+
/// (indent/dedent) instead of being intercepted for focus traversal.
325+
/// </summary>
326+
public bool WantsTabKey => _isEditing && !_readOnly;
327+
322328
/// <summary>
323329
/// Gets or sets when the horizontal scrollbar is displayed.
324330
/// </summary>

0 commit comments

Comments
 (0)