Skip to content

Commit 380cfdc

Browse files
committed
feat: focus management redesign — FocusManager replaces FocusCoordinator/FocusStateService
Introduces a unified FocusManager as the single source of truth for focus within each Window, replacing the old FocusCoordinator/FocusStateService pair and all _hasFocus fields across controls. Key changes: - New FocusManager, WindowRootScope, IFocusScope interface - IFocusScope implemented on SPC, HGrid, NavigationView, ToolbarControl - All controls use FocusManager.IsFocused() / IsInFocusPath() for HasFocus - FocusCoordinator and FocusStateService removed - Tab routing goes through FocusManager.MoveFocus (not ProcessKey) - HGrid/SPC GetFocusableChildren handle nested transparent scopes - NavigationView wraps nav→toolbar→content→nav with toolbar skip when hidden - Comprehensive test suite: FocusNavigationTests, TabTraversalTests, FocusRegressionTests, NavigationViewFocusScopeTests, etc. All 2450 tests pass.
1 parent c348e89 commit 380cfdc

120 files changed

Lines changed: 3645 additions & 4289 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

SharpConsoleUI.Tests/Controls/DatePickerControlTests.cs

Lines changed: 93 additions & 74 deletions
Large diffs are not rendered by default.

SharpConsoleUI.Tests/Controls/DropdownControlTests.cs

Lines changed: 40 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
// License: MIT
77
// -----------------------------------------------------------------------
88

9+
using SharpConsoleUI;
910
using SharpConsoleUI.Controls;
1011
using SharpConsoleUI.Core;
1112
using SharpConsoleUI.Drivers;
@@ -32,11 +33,14 @@ private static DropdownControl CreateDropdown(string prompt = "Select:", params
3233
return dd;
3334
}
3435

35-
private static DropdownControl CreateFocusedDropdown(string prompt = "Select:", params string[] items)
36+
private static (DropdownControl dd, Window window) CreateFocusedDropdown(string prompt = "Select:", params string[] items)
3637
{
38+
var system = TestWindowSystemBuilder.CreateTestSystem();
39+
var window = new Window(system) { Width = 80, Height = 30 };
3740
var dd = CreateDropdown(prompt, items);
38-
dd.HasFocus = true;
39-
return dd;
41+
window.AddControl(dd);
42+
window.FocusManager.SetFocus(dd, FocusReason.Programmatic);
43+
return (dd, window);
4044
}
4145

4246
private static MouseEventArgs CreateMousePress(int x, int y)
@@ -335,31 +339,45 @@ public void StringItems_GetSet_WorksCorrectly()
335339
[Fact]
336340
public void SetFocus_GainingFocus_FiresGotFocus()
337341
{
342+
var system = TestWindowSystemBuilder.CreateTestSystem();
343+
var window = new Window(system) { Width = 80, Height = 30 };
344+
// Add a placeholder so it takes auto-focus, leaving dd unfocused
345+
window.AddControl(new ButtonControl { Text = "Placeholder" });
338346
var dd = CreateDropdown("S:", "A", "B");
339-
bool gotFocus = false;
340-
dd.GotFocus += (s, e) => gotFocus = true;
341-
dd.SetFocus(true, FocusReason.Keyboard);
342-
Assert.True(gotFocus);
347+
window.AddControl(dd);
348+
349+
FocusChangedEventArgs? args = null;
350+
window.FocusManager.FocusChanged += (_, e) => args = e;
351+
352+
window.FocusManager.SetFocus(dd, FocusReason.Keyboard);
353+
354+
Assert.NotNull(args);
355+
Assert.Equal(dd, args!.Current);
343356
Assert.True(dd.HasFocus);
344357
}
345358

346359
[Fact]
347360
public void SetFocus_LosingFocus_FiresLostFocus()
348361
{
349-
var dd = CreateFocusedDropdown("S:", "A", "B");
350-
bool lostFocus = false;
351-
dd.LostFocus += (s, e) => lostFocus = true;
352-
dd.SetFocus(false, FocusReason.Keyboard);
353-
Assert.True(lostFocus);
362+
var (dd, window) = CreateFocusedDropdown("S:", "A", "B");
363+
364+
FocusChangedEventArgs? args = null;
365+
window.FocusManager.FocusChanged += (_, e) => args = e;
366+
367+
window.FocusManager.SetFocus(null, FocusReason.Keyboard);
368+
369+
Assert.NotNull(args);
370+
Assert.Null(args!.Current);
371+
Assert.Equal(dd, args.Previous);
354372
Assert.False(dd.HasFocus);
355373
}
356374

357375
[Fact]
358376
public void SetFocus_LosingFocusWithDropdownOpen_ClosesDropdown()
359377
{
360-
var dd = CreateFocusedDropdown("S:", "A", "B");
361-
// We can't fully open dropdown without a Window, but we can test the state flag
362-
dd.SetFocus(false);
378+
var (dd, window) = CreateFocusedDropdown("S:", "A", "B");
379+
// Losing focus should close dropdown (side effect in SetFocus)
380+
window.FocusManager.SetFocus(null, FocusReason.Programmatic);
363381
Assert.False(dd.IsDropdownOpen);
364382
}
365383

@@ -518,7 +536,7 @@ public void HeaderWidth_UnicodeItems_MeasuresCorrectly()
518536
[Fact]
519537
public void ProcessInput_Enter_TogglesDropdown()
520538
{
521-
var dd = CreateFocusedDropdown("S:", "A", "B", "C");
539+
var (dd, _) = CreateFocusedDropdown("S:", "A", "B", "C");
522540
// Without a container Window, Enter won't create portal, but IsDropdownOpen should still toggle
523541
// The state is maintained internally even if portal creation fails
524542
var result = dd.ProcessKey(Key(ConsoleKey.Enter));
@@ -529,15 +547,15 @@ public void ProcessInput_Enter_TogglesDropdown()
529547
[Fact]
530548
public void ProcessInput_Escape_ClosesDropdown()
531549
{
532-
var dd = CreateFocusedDropdown("S:", "A", "B");
550+
var (dd, _) = CreateFocusedDropdown("S:", "A", "B");
533551
dd.ProcessKey(Key(ConsoleKey.Escape));
534552
Assert.False(dd.IsDropdownOpen);
535553
}
536554

537555
[Fact]
538556
public void ProcessInput_DownArrow_IncreasesHighlight()
539557
{
540-
var dd = CreateFocusedDropdown("S:", "A", "B", "C");
558+
var (dd, _) = CreateFocusedDropdown("S:", "A", "B", "C");
541559
dd.SelectedIndex = 0;
542560
var result = dd.ProcessKey(Key(ConsoleKey.DownArrow));
543561
// When closed, DownArrow opens the dropdown
@@ -548,7 +566,7 @@ public void ProcessInput_DownArrow_IncreasesHighlight()
548566
[Fact]
549567
public void ProcessInput_UpArrow_DecreasesHighlight()
550568
{
551-
var dd = CreateFocusedDropdown("S:", "A", "B", "C");
569+
var (dd, _) = CreateFocusedDropdown("S:", "A", "B", "C");
552570
dd.SelectedIndex = 2;
553571
// When closed, UpArrow does nothing
554572
var result = dd.ProcessKey(Key(ConsoleKey.UpArrow));
@@ -558,7 +576,7 @@ public void ProcessInput_UpArrow_DecreasesHighlight()
558576
[Fact]
559577
public void ProcessInput_Home_JumpsToFirst()
560578
{
561-
var dd = CreateFocusedDropdown("S:", "A", "B", "C");
579+
var (dd, _) = CreateFocusedDropdown("S:", "A", "B", "C");
562580
dd.SelectedIndex = 2;
563581
// When closed, Home does nothing
564582
var result = dd.ProcessKey(Key(ConsoleKey.Home));
@@ -568,7 +586,7 @@ public void ProcessInput_Home_JumpsToFirst()
568586
[Fact]
569587
public void ProcessInput_End_JumpsToLast()
570588
{
571-
var dd = CreateFocusedDropdown("S:", "A", "B", "C");
589+
var (dd, _) = CreateFocusedDropdown("S:", "A", "B", "C");
572590
dd.SelectedIndex = 0;
573591
// When closed, End does nothing
574592
var result = dd.ProcessKey(Key(ConsoleKey.End));
@@ -578,7 +596,7 @@ public void ProcessInput_End_JumpsToLast()
578596
[Fact]
579597
public void ProcessInput_Disabled_IgnoresInput()
580598
{
581-
var dd = CreateFocusedDropdown("S:", "A", "B");
599+
var (dd, _) = CreateFocusedDropdown("S:", "A", "B");
582600
dd.IsEnabled = false;
583601
dd.SelectedIndex = 0;
584602
var result = dd.ProcessKey(Key(ConsoleKey.DownArrow));

SharpConsoleUI.Tests/Controls/DropdownPortalTests.cs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ public void DropdownWithWindow_CanOpenAndClose()
237237
system.WindowStateService.AddWindow(window);
238238
system.Render.UpdateDisplay();
239239

240-
dd.SetFocus(true, FocusReason.Programmatic);
240+
window.FocusManager.SetFocus(dd, FocusReason.Programmatic);
241241
dd.IsDropdownOpen = true;
242242
Assert.True(dd.IsDropdownOpen);
243243

@@ -256,7 +256,7 @@ public void DropdownWithWindow_PortalBoundsNotEmpty()
256256
system.WindowStateService.AddWindow(window);
257257
system.Render.UpdateDisplay();
258258

259-
dd.SetFocus(true, FocusReason.Programmatic);
259+
window.FocusManager.SetFocus(dd, FocusReason.Programmatic);
260260
dd.IsDropdownOpen = true;
261261

262262
var bounds = dd.GetPortalBounds();
@@ -278,7 +278,7 @@ public void DropdownWithWindow_MaxVisibleItems_LimitsPortalHeight()
278278
system.WindowStateService.AddWindow(window);
279279
system.Render.UpdateDisplay();
280280

281-
dd.SetFocus(true, FocusReason.Programmatic);
281+
window.FocusManager.SetFocus(dd, FocusReason.Programmatic);
282282
dd.IsDropdownOpen = true;
283283

284284
var bounds = dd.GetPortalBounds();
@@ -300,7 +300,7 @@ public void DropdownWithWindow_FewItems_NoScrollIndicator()
300300
system.WindowStateService.AddWindow(window);
301301
system.Render.UpdateDisplay();
302302

303-
dd.SetFocus(true, FocusReason.Programmatic);
303+
window.FocusManager.SetFocus(dd, FocusReason.Programmatic);
304304
dd.IsDropdownOpen = true;
305305

306306
var bounds = dd.GetPortalBounds();
@@ -353,7 +353,7 @@ public void DropdownPortal_EmptyItems_HandleGracefully()
353353
system.Render.UpdateDisplay();
354354

355355
// Opening with no items should not crash
356-
dd.SetFocus(true, FocusReason.Programmatic);
356+
window.FocusManager.SetFocus(dd, FocusReason.Programmatic);
357357
dd.IsDropdownOpen = true;
358358

359359
var bounds = dd.GetPortalBounds();
@@ -374,7 +374,7 @@ public void DropdownPortal_SingleItem_Works()
374374
system.WindowStateService.AddWindow(window);
375375
system.Render.UpdateDisplay();
376376

377-
dd.SetFocus(true, FocusReason.Programmatic);
377+
window.FocusManager.SetFocus(dd, FocusReason.Programmatic);
378378
dd.IsDropdownOpen = true;
379379

380380
var bounds = dd.GetPortalBounds();
@@ -397,7 +397,7 @@ public void DropdownPortal_LargeItemList_ClampsToMaxVisible()
397397
system.WindowStateService.AddWindow(window);
398398
system.Render.UpdateDisplay();
399399

400-
dd.SetFocus(true, FocusReason.Programmatic);
400+
window.FocusManager.SetFocus(dd, FocusReason.Programmatic);
401401
dd.IsDropdownOpen = true;
402402

403403
var bounds = dd.GetPortalBounds();
@@ -421,7 +421,7 @@ public void DropdownPortal_NearBottomOfWindow_FlipsUp()
421421
system.WindowStateService.AddWindow(window);
422422
system.Render.UpdateDisplay();
423423

424-
dd.SetFocus(true, FocusReason.Programmatic);
424+
window.FocusManager.SetFocus(dd, FocusReason.Programmatic);
425425
dd.IsDropdownOpen = true;
426426

427427
// The portal should either flip up or be clamped

SharpConsoleUI.Tests/Controls/HorizontalGridControlTests.cs

Lines changed: 31 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -277,8 +277,6 @@ public void ProcessKey_Tab_CyclesThroughColumns()
277277
grid.AddColumn(col2);
278278

279279
// Give focus to the grid to initialize focus tracking
280-
grid.HasFocus = true;
281-
282280
var tabKey = new ConsoleKeyInfo('\t', ConsoleKey.Tab, false, false, false);
283281

284282
// First Tab should move focus from first to second
@@ -302,8 +300,6 @@ public void ProcessKey_ShiftTab_CyclesBackward()
302300
grid.AddColumn(col1);
303301
grid.AddColumn(col2);
304302

305-
grid.HasFocus = true;
306-
307303
var tabKey = new ConsoleKeyInfo('\t', ConsoleKey.Tab, false, false, false);
308304
var shiftTabKey = new ConsoleKeyInfo('\t', ConsoleKey.Tab, true, false, false);
309305

@@ -325,8 +321,6 @@ public void ProcessKey_Tab_NoInteractiveControls_ReturnsFalse()
325321
col1.AddContent(label);
326322

327323
grid.AddColumn(col1);
328-
grid.HasFocus = true;
329-
330324
var tabKey = new ConsoleKeyInfo('\t', ConsoleKey.Tab, false, false, false);
331325

332326
bool handled = grid.ProcessKey(tabKey);
@@ -350,9 +344,12 @@ public void SetFocusWithDirection_Forward_FocusesFirst()
350344
grid.AddColumn(col1);
351345
grid.AddColumn(col2);
352346

353-
grid.SetFocusWithDirection(true, backward: false);
347+
var (system, window) = ContainerTestHelpers.CreateTestEnvironment();
348+
window.AddControl(grid);
349+
system.WindowStateService.AddWindow(window);
350+
window.RenderAndGetVisibleContent();
354351

355-
// First focusable control should have focus
352+
// First focusable control should have focus (auto-focused via IFocusScope delegation)
356353
Assert.True(list1.HasFocus);
357354
}
358355

@@ -372,7 +369,13 @@ public void SetFocusWithDirection_Backward_FocusesLast()
372369
grid.AddColumn(col1);
373370
grid.AddColumn(col2);
374371

375-
grid.SetFocusWithDirection(true, backward: true);
372+
var (system, window) = ContainerTestHelpers.CreateTestEnvironment();
373+
window.AddControl(grid);
374+
system.WindowStateService.AddWindow(window);
375+
window.RenderAndGetVisibleContent();
376+
377+
// Use SwitchFocus with backward=true from nothing focused (wraps to last)
378+
window.SwitchFocus(backward: true);
376379

377380
// Last focusable control should have focus
378381
Assert.True(list2.HasFocus);
@@ -634,8 +637,9 @@ public void ProcessKey_Tab_SingleColumn_SingleControl_ExitsForward()
634637
col.AddContent(btn);
635638
grid.AddColumn(col);
636639

637-
// HasFocus = true triggers FocusChanged which focuses the button
638-
grid.HasFocus = true;
640+
var (system, window) = ContainerTestHelpers.CreateTestEnvironment();
641+
window.AddControl(grid);
642+
window.RenderAndGetVisibleContent();
639643
Assert.True(btn.HasFocus);
640644

641645
var tabKey = new ConsoleKeyInfo('\t', ConsoleKey.Tab, false, false, false);
@@ -661,8 +665,9 @@ public void ProcessKey_ShiftTab_FirstControl_ExitsBackward()
661665
grid.AddColumn(col1);
662666
grid.AddColumn(col2);
663667

664-
// HasFocus = true focuses btn1 (first focusable) via FocusChanged
665-
grid.HasFocus = true;
668+
var (system, window) = ContainerTestHelpers.CreateTestEnvironment();
669+
window.AddControl(grid);
670+
window.RenderAndGetVisibleContent();
666671
Assert.True(btn1.HasFocus);
667672

668673
var shiftTabKey = new ConsoleKeyInfo('\t', ConsoleKey.Tab, true, false, false);
@@ -691,8 +696,6 @@ public void SetFocus_AllControlsDisabled_NoChildFocused()
691696
grid.AddColumn(col1);
692697
grid.AddColumn(col2);
693698

694-
grid.HasFocus = true;
695-
696699
var tabKey = new ConsoleKeyInfo('\t', ConsoleKey.Tab, false, false, false);
697700
grid.ProcessKey(tabKey);
698701

@@ -722,8 +725,9 @@ public void Tab_SkipsDisabledControlInColumn()
722725
grid.AddColumn(col2);
723726
grid.AddColumn(col3);
724727

725-
// HasFocus = true focuses btn1 (first focusable) via FocusChanged
726-
grid.HasFocus = true;
728+
var (system, window) = ContainerTestHelpers.CreateTestEnvironment();
729+
window.AddControl(grid);
730+
window.RenderAndGetVisibleContent();
727731
Assert.True(btn1.HasFocus);
728732

729733
var tabKey = new ConsoleKeyInfo('\t', ConsoleKey.Tab, false, false, false);
@@ -745,8 +749,6 @@ public void ProcessKey_NonTabKey_NoFocusedChild_ReturnsFalse()
745749
col.AddContent(ContainerTestHelpers.CreateLabel("Static"));
746750
grid.AddColumn(col);
747751

748-
grid.HasFocus = true;
749-
750752
var rightArrow = new ConsoleKeyInfo('\0', ConsoleKey.RightArrow, false, false, false);
751753

752754
bool handled = grid.ProcessKey(rightArrow);
@@ -763,9 +765,11 @@ public void NotifyChildFocusChanged_SetsGridHasFocus()
763765
col.AddContent(btn);
764766
grid.AddColumn(col);
765767

766-
// Setting HasFocus = true triggers FocusChanged which focuses btn
767-
grid.HasFocus = true;
768+
var (system, window) = ContainerTestHelpers.CreateTestEnvironment();
769+
window.AddControl(grid);
770+
window.RenderAndGetVisibleContent();
768771

772+
// When btn has focus, grid.HasFocus should be true (it's in the focus path)
769773
Assert.True(btn.HasFocus);
770774
Assert.True(grid.HasFocus);
771775
}
@@ -793,8 +797,9 @@ public void ColumnVisibilityToggle_HiddenColumnSkippedInTabOrder()
793797
// Hide the middle column before focusing
794798
col2.Visible = false;
795799

796-
// HasFocus = true focuses btn1 via FocusChanged
797-
grid.HasFocus = true;
800+
var (system, window) = ContainerTestHelpers.CreateTestEnvironment();
801+
window.AddControl(grid);
802+
window.RenderAndGetVisibleContent();
798803
Assert.True(btn1.HasFocus);
799804

800805
var tabKey = new ConsoleKeyInfo('\t', ConsoleKey.Tab, false, false, false);
@@ -822,8 +827,9 @@ public void RemoveColumn_WithFocusedControl_ClearsFocus()
822827
grid.AddColumn(col1);
823828
grid.AddColumn(col2);
824829

825-
// HasFocus = true focuses btn1 via FocusChanged
826-
grid.HasFocus = true;
830+
var (system, window) = ContainerTestHelpers.CreateTestEnvironment();
831+
window.AddControl(grid);
832+
window.RenderAndGetVisibleContent();
827833
Assert.True(btn1.HasFocus);
828834

829835
// Now remove col1 (which has the focused control)

0 commit comments

Comments
 (0)