Skip to content

Commit 4cf4b17

Browse files
committed
Fix ScrollablePanel opaque focus container and cursor visibility
- Make ScrollablePanel a single-mode opaque focus container that always owns its children's focus lifecycle (Tab, Escape, mouse click) - Register ScrollablePanel children in DOM node map for cursor position lookups without adding them as layout children (prevents double-paint) - Auto-register child nodes in UpdateChildBounds fallback for children added after DOM build - Use FindDeepestFocusedControl for cursor shape lookup so nested PromptControl gets VerticalBar cursor instead of Block fallback - Add Escape key handling: unfocus child enters scroll mode, Tab restores - Fix focus navigation tests for auto-focus behavior in opaque containers
1 parent bf712e1 commit 4cf4b17

8 files changed

Lines changed: 304 additions & 74 deletions

File tree

SharpConsoleUI.Tests/FocusManagement/FocusNavigationTests.cs

Lines changed: 73 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -182,15 +182,21 @@ public void Tab_TraversesScrollablePanelChildren()
182182

183183
system.WindowStateService.AddWindow(window);
184184

185-
// Act & Assert - Tab through panel children
186-
system.FocusStateService.SetFocus(window, button1);
187-
Assert.True(button1.HasFocus);
185+
// Panel is auto-focused by AddControl (first interactive control)
186+
// which delegates focus to its first child (button1)
187+
Assert.True(panel.HasFocus, "Panel should be auto-focused after AddControl");
188+
Assert.True(button1.HasFocus, "First child should be focused via delegation");
188189

189-
window.SwitchFocus(backward: false);
190-
Assert.True(button2.HasFocus);
190+
// Internal Tab within panel: button1 → button2 (panel handles this)
191+
var tabKey = new ConsoleKeyInfo('\t', ConsoleKey.Tab, false, false, false);
192+
panel.ProcessKey(tabKey);
193+
Assert.True(panel.HasFocus, "Panel should still have focus during internal Tab");
194+
Assert.True(button2.HasFocus, "Second child should be focused via internal Tab");
191195

196+
// SwitchFocus advances past panel → buttonAfter
192197
window.SwitchFocus(backward: false);
193-
Assert.True(buttonAfter.HasFocus);
198+
Assert.True(buttonAfter.HasFocus, "Control after panel should be focused");
199+
Assert.False(panel.HasFocus, "Panel should have lost focus");
194200
}
195201

196202
[Fact]
@@ -378,15 +384,17 @@ public void Tab_AfterContainerBecomesHidden_SkipsItsChildren()
378384

379385
system.WindowStateService.AddWindow(window);
380386

381-
// Initially, button2 is reachable
387+
// Initially, button2 is reachable through panel
382388
system.FocusStateService.SetFocus(window, button1);
383389
window.SwitchFocus(backward: false);
384-
Assert.True(button2.HasFocus);
390+
// Panel is opaque: panel gets focus and delegates to button2
391+
Assert.True(panel.HasFocus, "Panel should be focused");
392+
Assert.True(button2.HasFocus, "Button2 should be focused via panel delegation");
385393

386394
// Act - Hide panel
387395
panel.Visible = false;
388396

389-
// Assert - After hiding, button2 should not be reachable
397+
// Assert - After hiding, panel and its children should not be reachable
390398
system.FocusStateService.SetFocus(window, button1);
391399
window.SwitchFocus(backward: false);
392400
Assert.False(button2.HasFocus); // Skipped
@@ -398,7 +406,7 @@ public void Tab_AfterContainerBecomesHidden_SkipsItsChildren()
398406
#region ScrollablePanel Smart Focus Tests
399407

400408
[Fact]
401-
public void ScrollablePanel_WithFocusableChildren_ChildrenGetFocus()
409+
public void ScrollablePanel_WithFocusableChildren_PanelAndChildGetFocus()
402410
{
403411
// Arrange
404412
var system = TestWindowSystemBuilder.CreateTestSystem();
@@ -412,10 +420,10 @@ public void ScrollablePanel_WithFocusableChildren_ChildrenGetFocus()
412420

413421
system.WindowStateService.AddWindow(window);
414422

415-
// Act & Assert - Child gets focus, not panel
423+
// Act & Assert - Panel is an opaque container: it gets focus AND delegates to child
416424
window.SwitchFocus(backward: false);
417-
Assert.True(button.HasFocus);
418-
Assert.False(panel.HasFocus); // Panel should not be focusable when has focusable children
425+
Assert.True(panel.HasFocus, "Panel should be focused (opaque container)");
426+
Assert.True(button.HasFocus, "Child should be focused via panel delegation");
419427
}
420428

421429
[Fact]
@@ -555,12 +563,14 @@ public void Tab_TraversesColumnWithScrollablePanel()
555563

556564
system.WindowStateService.AddWindow(window);
557565

558-
// Act & Assert - Tab should traverse into panel, then to next column
559-
system.FocusStateService.SetFocus(window, button1);
560-
Assert.True(button1.HasFocus);
566+
// Grid auto-focuses → delegates to panel → delegates to button1
567+
Assert.True(panel.HasFocus, "Panel should be auto-focused via grid delegation");
568+
Assert.True(button1.HasFocus, "Button1 should be focused via panel delegation");
561569

570+
// SwitchFocus advances past panel → button2
562571
window.SwitchFocus(backward: false);
563-
Assert.True(button2.HasFocus);
572+
Assert.True(button2.HasFocus, "Button in second column should be focused");
573+
Assert.False(panel.HasFocus, "Panel should have lost focus");
564574
}
565575

566576
[Fact]
@@ -638,12 +648,15 @@ public void Tab_TraversesStickyTopControls()
638648

639649
system.WindowStateService.AddWindow(window);
640650

641-
// Act & Assert - Tab should traverse sticky controls normally
642-
system.FocusStateService.SetFocus(window, stickyButton);
643-
Assert.True(stickyButton.HasFocus);
644-
651+
// Act & Assert - Tab reaches panel, which delegates to first child (stickyButton)
645652
window.SwitchFocus(backward: false);
646-
Assert.True(normalButton.HasFocus);
653+
Assert.True(panel.HasFocus, "Panel should be focused");
654+
Assert.True(stickyButton.HasFocus, "Sticky button should be focused via delegation");
655+
656+
// Internal Tab within panel: stickyButton → normalButton
657+
var tabKey = new ConsoleKeyInfo('\t', ConsoleKey.Tab, false, false, false);
658+
panel.ProcessKey(tabKey);
659+
Assert.True(normalButton.HasFocus, "Normal button should be focused via internal Tab");
647660
}
648661

649662
[Fact]
@@ -665,10 +678,15 @@ public void Tab_TraversesStickyBottomControls()
665678

666679
system.WindowStateService.AddWindow(window);
667680

668-
// Act & Assert - Tab should include sticky bottom controls
669-
system.FocusStateService.SetFocus(window, normalButton);
681+
// Act & Assert - Tab reaches panel, delegates to first child (normalButton)
670682
window.SwitchFocus(backward: false);
671-
Assert.True(stickyButton.HasFocus);
683+
Assert.True(panel.HasFocus, "Panel should be focused");
684+
Assert.True(normalButton.HasFocus, "Normal button should be focused via delegation");
685+
686+
// Internal Tab: normalButton → stickyButton
687+
var tabKey = new ConsoleKeyInfo('\t', ConsoleKey.Tab, false, false, false);
688+
panel.ProcessKey(tabKey);
689+
Assert.True(stickyButton.HasFocus, "Sticky bottom button should be focused via internal Tab");
672690
}
673691

674692
[Fact]
@@ -692,13 +710,19 @@ public void Tab_TraversesMixedStickyPositions()
692710

693711
system.WindowStateService.AddWindow(window);
694712

695-
// Act & Assert - Tab should traverse all in order
696-
system.FocusStateService.SetFocus(window, stickyTop);
713+
// Act & Assert - Tab reaches panel, delegates to first child (stickyTop)
697714
window.SwitchFocus(backward: false);
698-
Assert.True(normal.HasFocus);
715+
Assert.True(panel.HasFocus, "Panel should be focused");
716+
Assert.True(stickyTop.HasFocus, "Sticky top should be focused via delegation");
699717

700-
window.SwitchFocus(backward: false);
701-
Assert.True(stickyBottom.HasFocus);
718+
// Internal Tab: stickyTop → normal
719+
var tabKey = new ConsoleKeyInfo('\t', ConsoleKey.Tab, false, false, false);
720+
panel.ProcessKey(tabKey);
721+
Assert.True(normal.HasFocus, "Normal button should be focused via internal Tab");
722+
723+
// Internal Tab: normal → stickyBottom
724+
panel.ProcessKey(tabKey);
725+
Assert.True(stickyBottom.HasFocus, "Sticky bottom should be focused via internal Tab");
702726
}
703727

704728
#endregion
@@ -736,19 +760,22 @@ public void Tab_TraversesGridWithNestedPanelsAndSplitters()
736760

737761
system.WindowStateService.AddWindow(window);
738762

739-
// Tab order: button1 (panel1) → button2 (panel1) → splitter → button3 (column2)
740-
system.FocusStateService.SetFocus(window, button1);
763+
// Grid auto-focuses → delegates to panel1 → delegates to button1
764+
Assert.True(panel1.HasFocus, "Panel should be auto-focused via grid delegation");
765+
Assert.True(button1.HasFocus, "First child should be focused via delegation");
741766

742-
// Tab 1: button1 → button2
743-
window.SwitchFocus(backward: false);
744-
Assert.True(button2.HasFocus);
767+
// Internal Tab: button1 → button2 (panel handles this)
768+
var tabKey = new ConsoleKeyInfo('\t', ConsoleKey.Tab, false, false, false);
769+
panel1.ProcessKey(tabKey);
770+
Assert.True(panel1.HasFocus, "Panel should still have focus during internal Tab");
771+
Assert.True(button2.HasFocus, "Second child should be focused via internal Tab");
745772

746-
// Tab 2: button2 → splitter
773+
// SwitchFocus advances past panel → splitter
747774
window.SwitchFocus(backward: false);
748-
Assert.False(button2.HasFocus);
775+
Assert.False(panel1.HasFocus, "Panel should have lost focus");
749776
Assert.False(button3.HasFocus); // Splitter should have focus
750777

751-
// Tab 3: splitter → button3
778+
// Tab: splitter → button3
752779
window.SwitchFocus(backward: false);
753780
Assert.True(button3.HasFocus);
754781
}
@@ -777,10 +804,15 @@ public void Tab_TraversesGridWithStickyAndNormalControls()
777804

778805
system.WindowStateService.AddWindow(window);
779806

780-
// Act & Assert
781-
system.FocusStateService.SetFocus(window, stickyTop);
807+
// Act & Assert - Tab reaches panel, delegates to stickyTop
782808
window.SwitchFocus(backward: false);
783-
Assert.True(normal.HasFocus);
809+
Assert.True(panel.HasFocus, "Panel should be focused");
810+
Assert.True(stickyTop.HasFocus, "Sticky top should be focused via delegation");
811+
812+
// Internal Tab: stickyTop → normal
813+
var tabKey = new ConsoleKeyInfo('\t', ConsoleKey.Tab, false, false, false);
814+
panel.ProcessKey(tabKey);
815+
Assert.True(normal.HasFocus, "Normal button should be focused via internal Tab");
784816
}
785817

786818
[Fact]

SharpConsoleUI/ConsoleWindowSystem.cs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -744,6 +744,7 @@ private void UpdateCursor()
744744
absoluteLeft >= ActiveWindow.Left && absoluteLeft < ActiveWindow.Left + ActiveWindow.Width &&
745745
absoluteTop >= ActiveWindow.Top && absoluteTop < ActiveWindow.Top + ActiveWindow.Height;
746746

747+
747748
if (isWithinBounds)
748749
{
749750
// Get the owner control if available
@@ -755,8 +756,16 @@ private void UpdateCursor()
755756
{
756757
ownerControl = windowControl;
757758

758-
// Check if control provides a preferred cursor shape
759-
if (windowControl is ICursorShapeProvider shapeProvider &&
759+
// Find the deepest focused control for cursor shape (e.g., PromptControl inside ScrollablePanel)
760+
var deepestControl = ActiveWindow.FindDeepestFocusedControl(interactiveContent);
761+
762+
// Check deepest control first, then fall back to top-level control
763+
if (deepestControl is ICursorShapeProvider deepShapeProvider &&
764+
deepShapeProvider.PreferredCursorShape.HasValue)
765+
{
766+
cursorShape = deepShapeProvider.PreferredCursorShape.Value;
767+
}
768+
else if (windowControl is ICursorShapeProvider shapeProvider &&
760769
shapeProvider.PreferredCursorShape.HasValue)
761770
{
762771
cursorShape = shapeProvider.PreferredCursorShape.Value;

0 commit comments

Comments
 (0)