Skip to content

Commit 447f93e

Browse files
committed
fix: update coordinator focus path after mouse-click on Nth control in ScrollablePanel
When clicking a directly-focusable control in a ScrollablePanel, HandleClickFocus called RequestFocus(SPC) which delegated to the first child via SetFocus(Programmatic), setting the coordinator path to that first child. ProcessMouseEvent then correctly gave visual focus to the actually clicked child but did not update the coordinator path, causing key routing and Tab navigation to target the first child instead. Fix: call UpdateCoordinatorFocusPath(child) after SetFocus(true, Mouse) in ProcessMouseEvent so the path always reflects the actually clicked control. Adds regression tests covering the full dispatch path (HandleClickFocus + ProcessMouseEvent) to prevent future drift between visual focus state and coordinator routing path.
1 parent d404b95 commit 447f93e

3 files changed

Lines changed: 356 additions & 1 deletion

File tree

SharpConsoleUI.Tests/FocusManagement/MouseFocusKeyboardRoutingTests.cs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,79 @@ public void MouseFocus_ThenTab_ContinuesCorrectly()
228228
Assert.False(button1.HasFocus, "Button1 should have lost focus after Tab");
229229
}
230230

231+
#region Full Dispatch Path (HandleClickFocus + ProcessMouseEvent)
232+
233+
/// <summary>
234+
/// Regression: When clicking the Nth directly-focusable control in an SPC,
235+
/// the coordinator path must point to that control, not the first one.
236+
///
237+
/// Root cause: HandleClickFocus(SPC) → RequestFocus(SPC) → SPC.SetFocus(Programmatic)
238+
/// delegates to the FIRST focusable child and sets the coordinator path there.
239+
/// SPC.ProcessMouseEvent then correctly focuses the clicked child visually, but
240+
/// did not update the coordinator path — leaving it pointing to child #1.
241+
/// Result: key routing and Tab both targeted child #1 instead of the clicked child.
242+
/// </summary>
243+
[Fact]
244+
public void MouseClick_ThirdControlInPanel_CoordinatorPathPointsToThirdControl()
245+
{
246+
// Arrange: SPC with 3 stacked buttons (each 1 line tall, so y=0,1,2)
247+
var panel = new ScrollablePanelControl { Height = 10 };
248+
var button1 = new ButtonControl { Text = "Button1" };
249+
var button2 = new ButtonControl { Text = "Button2" };
250+
var button3 = new ButtonControl { Text = "Button3" };
251+
panel.AddControl(button1);
252+
panel.AddControl(button2);
253+
panel.AddControl(button3);
254+
255+
var (system, window) = ContainerTestHelpers.CreateTestEnvironment();
256+
window.AddControl(panel);
257+
window.RenderAndGetVisibleContent();
258+
259+
// Act: simulate full window dispatch — HandleClickFocus first (sets path to child #1),
260+
// then ProcessMouseEvent (must correct the path to the actually clicked child).
261+
window.FocusCoord!.HandleClickFocus(panel);
262+
var click = CreateClick(0, 2); // y=2 → button3
263+
panel.ProcessMouseEvent(click);
264+
265+
// Assert: coordinator path leaf must be button3, not button1
266+
Assert.True(button3.HasFocus, "Button3 should have focus after clicking it");
267+
Assert.False(button1.HasFocus, "Button1 should NOT have focus");
268+
Assert.Same(button3, window.FocusCoord!.FocusedLeaf); // path must point to clicked button3, not button1
269+
}
270+
271+
[Fact]
272+
public void MouseClick_SecondControl_TabAdvancesFromSecondNotFirst()
273+
{
274+
// Arrange: SPC with 3 stacked buttons
275+
var panel = new ScrollablePanelControl { Height = 10 };
276+
var button1 = new ButtonControl { Text = "Button1" };
277+
var button2 = new ButtonControl { Text = "Button2" };
278+
var button3 = new ButtonControl { Text = "Button3" };
279+
panel.AddControl(button1);
280+
panel.AddControl(button2);
281+
panel.AddControl(button3);
282+
283+
var (system, window) = ContainerTestHelpers.CreateTestEnvironment();
284+
window.AddControl(panel);
285+
window.RenderAndGetVisibleContent();
286+
287+
// Act: click button2 via full dispatch path
288+
window.FocusCoord!.HandleClickFocus(panel);
289+
panel.ProcessMouseEvent(CreateClick(0, 1)); // y=1 → button2
290+
291+
Assert.True(button2.HasFocus, "Button2 should have focus");
292+
293+
// Act: Tab should advance to button3 (not button1)
294+
var tab = new ConsoleKeyInfo('\t', ConsoleKey.Tab, false, false, false);
295+
panel.ProcessKey(tab);
296+
297+
Assert.True(button3.HasFocus, "Tab from button2 should land on button3");
298+
Assert.False(button2.HasFocus, "Button2 should have lost focus after Tab");
299+
Assert.False(button1.HasFocus, "Button1 should remain unfocused");
300+
}
301+
302+
#endregion
303+
231304
#region Edge Cases - Disabled Controls
232305

233306
[Fact]

SharpConsoleUI/Controls/ScrollablePanelControl/ScrollablePanelControl.Mouse.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,14 +328,19 @@ public bool ProcessMouseEvent(MouseEventArgs args)
328328
{
329329
// Set focus on clicked child directly
330330
((IFocusableControl)child).SetFocus(true, FocusReason.Mouse);
331+
// Correct the coordinator path to the actually clicked child.
332+
// RequestFocus(SPC) → SPC.SetFocus(Programmatic) delegates to the
333+
// FIRST focusable child and sets the path there. We must update it
334+
// to the child the user actually clicked, or key routing and Tab
335+
// will target the first child instead.
336+
UpdateCoordinatorFocusPath((IInteractiveControl)child);
331337
}
332338
// For containers (e.g. HorizontalGrid with CanReceiveFocus=false):
333339
// Don't call SetFocus — let the mouse forwarding + the container's own
334340
// mouse focus handling (Fix 1) set focus on the actual child control.
335341

336342
_hasFocus = true;
337343
_lastInternalFocusedChild = null;
338-
// Path is updated by the SetFocus notification chain or coordinator.HandleClickFocus
339344
log?.LogTrace($"ScrollPanel.ProcessMouseEvent: focused child {child.GetType().Name}", "Focus");
340345
}
341346

0 commit comments

Comments
 (0)