Skip to content

Commit da664aa

Browse files
committed
Fix mouse-focused controls in HorizontalGrid inside ScrollablePanel not receiving keyboard input
HorizontalGrid.ProcessMouseEvent now updates _focusedContent and _hasFocus when a focusable child is clicked, and calls SetFocus on the child. Previously, mouse clicks forwarded to the child but never updated the grid's focus tracking, so ProcessKey couldn't route keys to the focused control. ScrollablePanel.ProcessMouseEvent now handles containers with focusable descendants (e.g. HorizontalGrid with CanReceiveFocus=false) by checking CanChildReceiveFocus in addition to CanReceiveFocus. This ensures _focusedChild and _hasFocus are set for container children during mouse clicks.
1 parent e6983dc commit da664aa

3 files changed

Lines changed: 266 additions & 5 deletions

File tree

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
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.Controls;
10+
using SharpConsoleUI.Drivers;
11+
using SharpConsoleUI.Events;
12+
using SharpConsoleUI.Tests.Controls;
13+
using SharpConsoleUI.Tests.Infrastructure;
14+
using System.Drawing;
15+
using Xunit;
16+
17+
namespace SharpConsoleUI.Tests.FocusManagement;
18+
19+
/// <summary>
20+
/// Tests that mouse-clicking a focusable control inside a HorizontalGrid
21+
/// inside a ScrollablePanel correctly routes subsequent keyboard input
22+
/// to that control (not to the panel's scroll handler).
23+
/// </summary>
24+
public class MouseFocusKeyboardRoutingTests
25+
{
26+
/// <summary>
27+
/// Creates a ScrollablePanel containing a HorizontalGrid with two buttons in separate columns.
28+
/// Returns everything needed to test mouse/keyboard routing.
29+
/// </summary>
30+
private (ScrollablePanelControl panel, HorizontalGridControl grid,
31+
ButtonControl button1, ButtonControl button2,
32+
ConsoleWindowSystem system, Window window)
33+
CreatePanelWithGridAndButtons()
34+
{
35+
var panel = new ScrollablePanelControl();
36+
panel.Height = 10;
37+
38+
var grid = new HorizontalGridControl();
39+
var col1 = new ColumnContainer(grid);
40+
var col2 = new ColumnContainer(grid);
41+
42+
var button1 = new ButtonControl { Text = "Button1" };
43+
var button2 = new ButtonControl { Text = "Button2" };
44+
45+
col1.AddContent(button1);
46+
col2.AddContent(button2);
47+
grid.AddColumn(col1);
48+
grid.AddColumn(col2);
49+
50+
panel.AddControl(grid);
51+
52+
var (system, window) = ContainerTestHelpers.CreateTestEnvironment();
53+
window.AddControl(panel);
54+
window.RenderAndGetVisibleContent();
55+
56+
return (panel, grid, button1, button2, system, window);
57+
}
58+
59+
/// <summary>
60+
/// Helper to create a mouse click event at the given panel-relative position.
61+
/// </summary>
62+
private static MouseEventArgs CreateClick(int x, int y)
63+
{
64+
return ContainerTestHelpers.CreateClick(x, y);
65+
}
66+
67+
[Fact]
68+
public void MouseClick_OnControlInGridInPanel_SetsFocusChain()
69+
{
70+
// Arrange
71+
var (panel, grid, button1, button2, system, window) = CreatePanelWithGridAndButtons();
72+
73+
// Act: click on button1 (at position 0,0 inside the panel content area)
74+
var click = CreateClick(0, 0);
75+
panel.ProcessMouseEvent(click);
76+
77+
// Assert: focus chain is set correctly
78+
Assert.True(button1.HasFocus, "Button1 should have focus after mouse click");
79+
Assert.True(grid.HasFocus, "HorizontalGrid should track focus (HasFocus=true)");
80+
Assert.True(panel.HasFocus, "ScrollablePanel should have focus");
81+
}
82+
83+
[Fact]
84+
public void KeyboardInput_ReachesMouseFocusedControlInGrid()
85+
{
86+
// Arrange: use a slider that responds to keyboard input
87+
var panel = new ScrollablePanelControl();
88+
panel.Height = 15;
89+
90+
var grid = new HorizontalGridControl();
91+
var col1 = new ColumnContainer(grid);
92+
93+
var slider = new SliderControl
94+
{
95+
Orientation = SliderOrientation.Horizontal,
96+
MinValue = 0,
97+
MaxValue = 100,
98+
Value = 50,
99+
Step = 1
100+
};
101+
102+
col1.AddContent(slider);
103+
grid.AddColumn(col1);
104+
panel.AddControl(grid);
105+
106+
var (system, window) = ContainerTestHelpers.CreateTestEnvironment();
107+
window.AddControl(panel);
108+
window.RenderAndGetVisibleContent();
109+
110+
// Act: click on the slider area to focus it
111+
var click = CreateClick(0, 0);
112+
panel.ProcessMouseEvent(click);
113+
114+
// Verify focus chain is set
115+
Assert.True(slider.HasFocus, "Slider should have focus after click");
116+
Assert.True(grid.HasFocus, "Grid should track focus");
117+
Assert.True(panel.HasFocus, "Panel should have focus");
118+
119+
// Act: send Right arrow key to increase slider value
120+
var rightArrow = new ConsoleKeyInfo('\0', ConsoleKey.RightArrow, false, false, false);
121+
bool handled = panel.ProcessKey(rightArrow);
122+
123+
// Assert: key was handled by the slider, not by the panel's scroll handler
124+
Assert.True(handled, "Key should be handled");
125+
Assert.Equal(51, slider.Value);
126+
}
127+
128+
[Fact]
129+
public void MouseClick_DifferentControlInGrid_SwitchesFocus()
130+
{
131+
// Arrange: use explicit column widths so we know exact X positions
132+
var panel = new ScrollablePanelControl();
133+
panel.Height = 10;
134+
135+
var grid = new HorizontalGridControl();
136+
var col1 = new ColumnContainer(grid);
137+
col1.Width = 30;
138+
var col2 = new ColumnContainer(grid);
139+
col2.Width = 30;
140+
141+
var button1 = new ButtonControl { Text = "Button1" };
142+
var button2 = new ButtonControl { Text = "Button2" };
143+
144+
col1.AddContent(button1);
145+
col2.AddContent(button2);
146+
grid.AddColumn(col1);
147+
grid.AddColumn(col2);
148+
149+
panel.AddControl(grid);
150+
151+
var (system, window) = ContainerTestHelpers.CreateTestEnvironment();
152+
window.AddControl(panel);
153+
window.RenderAndGetVisibleContent();
154+
155+
// Act: click on button1 first (x=0 is inside col1)
156+
var click1 = CreateClick(0, 0);
157+
panel.ProcessMouseEvent(click1);
158+
Assert.True(button1.HasFocus, "Button1 should have focus after first click");
159+
160+
// Act: click on button2 (in col2, which starts at col1's actual width)
161+
var click2 = CreateClick(col1.ActualWidth, 0);
162+
grid.ProcessMouseEvent(click2);
163+
164+
// Assert: button2 has focus, button1 doesn't
165+
// Assert: button2 has focus, button1 doesn't
166+
Assert.True(button2.HasFocus, "Button2 should have focus after clicking it");
167+
Assert.False(button1.HasFocus, "Button1 should have lost focus");
168+
}
169+
170+
[Fact]
171+
public void MouseClick_OutsideGrid_ClearsGridFocus()
172+
{
173+
// Arrange: panel with a grid AND a standalone button below it
174+
var panel = new ScrollablePanelControl();
175+
panel.Height = 15;
176+
177+
var grid = new HorizontalGridControl();
178+
var col1 = new ColumnContainer(grid);
179+
var button1 = new ButtonControl { Text = "InGrid" };
180+
col1.AddContent(button1);
181+
grid.AddColumn(col1);
182+
183+
var standaloneButton = new ButtonControl { Text = "Standalone" };
184+
185+
panel.AddControl(grid);
186+
panel.AddControl(standaloneButton);
187+
188+
var (system, window) = ContainerTestHelpers.CreateTestEnvironment();
189+
window.AddControl(panel);
190+
window.RenderAndGetVisibleContent();
191+
192+
// Act: click button in grid to focus it
193+
var click1 = CreateClick(0, 0);
194+
panel.ProcessMouseEvent(click1);
195+
Assert.True(button1.HasFocus, "Button1 in grid should have focus");
196+
Assert.True(grid.HasFocus, "Grid should track focus");
197+
198+
// Act: click on standalone button (below the grid)
199+
// The standalone button is after the grid, so its Y position is after the grid's height
200+
int standaloneY = standaloneButton.ActualY > 0 ? standaloneButton.ActualY : 3;
201+
var click2 = CreateClick(0, standaloneY);
202+
panel.ProcessMouseEvent(click2);
203+
204+
// Assert: grid focus is cleared, standalone has focus
205+
Assert.True(standaloneButton.HasFocus, "Standalone button should have focus");
206+
Assert.False(button1.HasFocus, "Button in grid should have lost focus");
207+
}
208+
209+
[Fact]
210+
public void MouseFocus_ThenTab_ContinuesCorrectly()
211+
{
212+
// Arrange
213+
var (panel, grid, button1, button2, system, window) = CreatePanelWithGridAndButtons();
214+
215+
// Act: click on button1 to focus it via mouse
216+
var click = CreateClick(0, 0);
217+
panel.ProcessMouseEvent(click);
218+
Assert.True(button1.HasFocus, "Button1 should have focus from mouse click");
219+
220+
// Act: press Tab to move to button2
221+
var tabKey = new ConsoleKeyInfo('\t', ConsoleKey.Tab, false, false, false);
222+
bool handled = panel.ProcessKey(tabKey);
223+
224+
// Assert: Tab should navigate within the grid
225+
// The panel delegates to grid, grid handles Tab internally
226+
Assert.True(handled, "Tab should be handled");
227+
Assert.True(button2.HasFocus, "Button2 should have focus after Tab");
228+
Assert.False(button1.HasFocus, "Button1 should have lost focus after Tab");
229+
}
230+
}

SharpConsoleUI/Controls/HorizontalGridControl/HorizontalGridControl.Mouse.cs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,28 @@ public bool ProcessMouseEvent(MouseEventArgs args)
2424
var clickedControl = GetControlAtPosition(args.Position);
2525
if (clickedControl != null)
2626
{
27-
// Window now handles focus via DOM tree - just forward mouse event to child
27+
// Update focus tracking for the clicked control so ProcessKey can route to it.
28+
// This mirrors what NotifyChildFocusChanged does but triggered directly from mouse handling,
29+
// which is needed because the notification chain from child.SetFocus may not reach us
30+
// (ColumnContainer doesn't implement IWindowControl, breaking the walk-up).
31+
if (clickedControl is IFocusableControl focusable && focusable.CanReceiveFocus)
32+
{
33+
// Unfocus previously focused content
34+
if (_focusedContent != null && _focusedContent != clickedControl)
35+
{
36+
if (_focusedContent is IFocusableControl oldFc)
37+
oldFc.SetFocus(false, FocusReason.Mouse);
38+
else
39+
_focusedContent.HasFocus = false;
40+
}
41+
42+
_focusedContent = clickedControl;
43+
_hasFocus = true;
44+
45+
// Set focus on the clicked control (some controls like SliderControl
46+
// do this internally in ProcessMouseEvent, but others like ButtonControl don't)
47+
focusable.SetFocus(true, FocusReason.Mouse);
48+
}
2849

2950
// Propagate mouse event to the clicked control if it supports mouse events
3051
if (clickedControl is IMouseAwareControl mouseAware && mouseAware.WantsMouseEvents)

SharpConsoleUI/Controls/ScrollablePanelControl/ScrollablePanelControl.Mouse.cs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -305,8 +305,11 @@ public bool ProcessMouseEvent(MouseEventArgs args)
305305

306306
if (child != null)
307307
{
308-
// Set focus on clicked child (if focusable)
309-
if (child is IFocusableControl focusable && focusable.CanReceiveFocus)
308+
// Set focus on clicked child (if directly focusable or a container with focusable descendants)
309+
bool directlyFocusable = child is IFocusableControl focusable && focusable.CanReceiveFocus;
310+
bool containerWithFocusableChildren = !directlyFocusable && CanChildReceiveFocus(child);
311+
312+
if (directlyFocusable || containerWithFocusableChildren)
310313
{
311314
// Clear focus from other children
312315
List<IWindowControl> mouseSnapshot;
@@ -319,8 +322,15 @@ public bool ProcessMouseEvent(MouseEventArgs args)
319322
}
320323
}
321324

322-
// Set focus on clicked child
323-
focusable.SetFocus(true, FocusReason.Mouse);
325+
if (directlyFocusable)
326+
{
327+
// Set focus on clicked child directly
328+
((IFocusableControl)child).SetFocus(true, FocusReason.Mouse);
329+
}
330+
// For containers (e.g. HorizontalGrid with CanReceiveFocus=false):
331+
// Don't call SetFocus — let the mouse forwarding + the container's own
332+
// mouse focus handling (Fix 1) set focus on the actual child control.
333+
324334
if (child is IInteractiveControl interactive)
325335
_focusedChild = interactive;
326336
// Mark panel as focused so ProcessKey routes keys to _focusedChild.

0 commit comments

Comments
 (0)