Skip to content

Commit db5ba88

Browse files
committed
Add shared overlay focus restoration
1 parent a9df0ff commit db5ba88

File tree

18 files changed

+292
-50
lines changed

18 files changed

+292
-50
lines changed

site/docs/controls/commandpalette.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,13 @@ When open:
5252
- Press Down from the search box to move directly to the next result
5353
- Resize or drag the palette window with the mouse
5454
- Press Esc to close
55+
- Closing the palette returns focus to the control that was focused before `Show()`
5556

5657
## Host chrome
5758

58-
When displayed via `CommandPalette.Show()`, the palette is hosted inside a resizable dialog window. You can still wrap the palette content with a template visual using `CommandPaletteStyle`:
59+
When displayed via `CommandPalette.Show()`, the palette is hosted inside a resizable dialog window. The host dialog takes
60+
care of restoring the previous focus when the palette closes. You can still wrap the palette content with a template visual
61+
using `CommandPaletteStyle`:
5962

6063
```csharp
6164
using XenoAtom.Terminal.UI.Controls;

site/docs/controls/dialog.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ title: Dialog
1414
Dialogs are displayed in fullscreen apps and typically:
1515

1616
- take focus when shown
17+
- restore the previously focused control when closed
1718
- can be modal or non-modal depending on configuration
1819
- can be moved by dragging the top border row
1920
- highlights the top border row on hover so the move affordance is visible
@@ -29,6 +30,7 @@ Dialogs are displayed in fullscreen apps and typically:
2930
- `TopRightText`, `BottomLeftText`, and `BottomRightText` let you decorate the dialog border the same way `Group` does.
3031
- `DialogStyle` lets you override border glyphs, surface/border styles, label cutout styling, and the hover styling used for resize handles and the move bar.
3132
- Top and bottom hover affordances respect border-label cutouts so the highlight does not paint over those visuals.
33+
- Focus restoration is automatic, so showing a dialog does not require manual save/restore focus code in callers.
3234

3335
```csharp
3436
var dialog = new Dialog()

site/docs/controls/popup.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,11 @@ Popups participate in focus. Common dismissal patterns:
3434
- close on `Escape`,
3535
- close when clicking outside,
3636
- close when the popup loses focus (for transient UI).
37+
- restore the previously focused control when the popup closes.
3738

3839
> [!IMPORTANT]
39-
> When you show a popup from a control, keep the focus rules explicit: decide whether focus should move into the popup
40-
> (typical for menus) or remain on the originating control (typical for passive tooltips).
40+
> Interactive popups save the currently focused control when shown and restore it on close. This keeps dropdowns,
41+
> context menus, search popups, and similar overlays consistent without each caller having to manage focus manually.
4142
4243
## Defaults
4344

site/docs/input.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ Controls opt in to focus by setting `Focusable = true`.
5454
- Keyboard focus is kept within a focus scope managed by `TerminalApp`.
5555
- On mouse down (or double click), `TerminalApp` walks up from the hit target to the nearest focusable ancestor and focuses it.
5656
- This is why clicking inside a complex control (like a text editor) typically focuses that editor.
57+
- `Popup` and `Dialog` automatically save the previously focused control when shown and restore it when closed.
5758

5859
If you implement a focusable control, use `HasFocus` / `HasFocusWithin` to render focus cues and to decide what keyboard behavior to enable.
5960

site/docs/readme.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ See also:
5050
- [Data Templating](data-templating.md) (DataTemplates, DataPresenter<T>, item templates)
5151
- [Culture](culture.md) (culture-aware value formatting)
5252
- [Layout](layout.md) (layout protocol, alignment, margin/padding)
53-
- [Input](input.md) (keyboard/mouse, focus, routed events, capture)
53+
- [Input](input.md) (keyboard/mouse, focus scopes, overlay focus restore, routed events, capture)
5454
- [Commands](commands.md) (commands, key sequences, key hints with CommandBar)
5555
- [Styling](styling.md) (Theme, styles, environment, brushes/gradients)
5656
- [Rendering](rendering.md) (cell buffer, diff renderer, performance)

src/XenoAtom.Terminal.UI.Tests/CommandPaletteTests.cs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,39 @@ public void CommandPalette_Restores_Focus_On_Close()
406406
Assert.AreSame(focusedBefore, driver.App.FocusedElement);
407407
}
408408

409+
[TestMethod]
410+
public void CommandPalette_Escape_Restores_Exact_Previous_Focus()
411+
{
412+
var first = new TextBox("First");
413+
var second = new TextBox("Second");
414+
var palette = new CommandPalette();
415+
var root = new VStack(first, second);
416+
417+
using var driver = new TerminalAppTestDriver(root, TerminalHostKind.Fullscreen, new TerminalSize(80, 16));
418+
driver.Tick();
419+
420+
driver.App.Focus(second);
421+
driver.Tick();
422+
423+
driver.App.AddGlobalCommand(new Command
424+
{
425+
Id = "cmd.open",
426+
LabelMarkup = "Open",
427+
Presentation = CommandPresentation.CommandPalette,
428+
Execute = _ => { },
429+
});
430+
431+
palette.Show();
432+
driver.Tick();
433+
434+
Assert.IsInstanceOfType(driver.App.FocusedElement, typeof(TextBox), "Expected the palette search box to take focus.");
435+
436+
driver.Backend.PushEvent(new TerminalKeyEvent { Key = TerminalKey.Escape });
437+
driver.Tick();
438+
439+
Assert.AreSame(second, driver.App.FocusedElement);
440+
}
441+
409442
[TestMethod]
410443
public void CommandPalette_Ranks_Word_Boundary_Matches_Ahead_Of_Later_Matches()
411444
{
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Copyright (c) Alexandre Mutel. All rights reserved.
2+
// Licensed under the BSD-Clause 2 license.
3+
// See license.txt file in the project root for full license information.
4+
5+
using XenoAtom.Terminal.UI.Controls;
6+
using XenoAtom.Terminal.UI.Hosting;
7+
8+
namespace XenoAtom.Terminal.UI.Tests;
9+
10+
[TestClass]
11+
public sealed class OverlayFocusRestoreTests
12+
{
13+
[TestMethod]
14+
public void Popup_Close_Restores_Previous_Focus()
15+
{
16+
var focusedBefore = new TextBox("Before");
17+
var anchor = new Button("Anchor");
18+
var popupFocus = new TextBox("Popup");
19+
var root = new VStack(focusedBefore, anchor, new TextBox("After"));
20+
21+
using var driver = new TerminalAppTestDriver(root, TerminalHostKind.Fullscreen);
22+
driver.Tick();
23+
24+
driver.App.Focus(focusedBefore);
25+
driver.Tick();
26+
27+
var popup = new Popup
28+
{
29+
Anchor = anchor,
30+
Content = popupFocus,
31+
MatchAnchorWidth = false,
32+
};
33+
34+
popup.Show();
35+
driver.Tick();
36+
37+
Assert.AreSame(popupFocus, driver.App.FocusedElement);
38+
39+
popup.Close();
40+
driver.Tick();
41+
42+
Assert.AreSame(focusedBefore, driver.App.FocusedElement);
43+
}
44+
45+
[TestMethod]
46+
public void Dialog_Close_Restores_Previous_Focus()
47+
{
48+
var focusedBefore = new TextBox("Before");
49+
var dialogFocus = new TextBox("Dialog");
50+
var root = new VStack(focusedBefore, new TextBox("After"));
51+
52+
using var driver = new TerminalAppTestDriver(root, TerminalHostKind.Fullscreen);
53+
driver.Tick();
54+
55+
driver.App.Focus(focusedBefore);
56+
driver.Tick();
57+
58+
var dialog = new Dialog
59+
{
60+
IsModal = true,
61+
Width = 20,
62+
Height = 6,
63+
Content = dialogFocus,
64+
};
65+
66+
dialog.Show();
67+
driver.Tick();
68+
69+
Assert.AreSame(dialogFocus, driver.App.FocusedElement);
70+
71+
dialog.Close();
72+
driver.Tick();
73+
74+
Assert.AreSame(focusedBefore, driver.App.FocusedElement);
75+
}
76+
77+
[TestMethod]
78+
public void Nested_Overlays_Restore_Focus_Per_Level()
79+
{
80+
var focusedBefore = new TextBox("Before");
81+
var dialogFocus = new TextBox("Dialog");
82+
var popupFocus = new TextBox("Popup");
83+
var root = new VStack(focusedBefore, new TextBox("After"));
84+
85+
using var driver = new TerminalAppTestDriver(root, TerminalHostKind.Fullscreen);
86+
driver.Tick();
87+
88+
driver.App.Focus(focusedBefore);
89+
driver.Tick();
90+
91+
var dialog = new Dialog
92+
{
93+
IsModal = true,
94+
Width = 20,
95+
Height = 6,
96+
Content = dialogFocus,
97+
};
98+
99+
dialog.Show();
100+
driver.Tick();
101+
102+
Assert.AreSame(dialogFocus, driver.App.FocusedElement);
103+
104+
var popup = new Popup
105+
{
106+
Anchor = dialogFocus,
107+
Content = popupFocus,
108+
MatchAnchorWidth = false,
109+
};
110+
111+
popup.Show();
112+
driver.Tick();
113+
114+
Assert.AreSame(popupFocus, driver.App.FocusedElement);
115+
116+
popup.Close();
117+
driver.Tick();
118+
119+
Assert.AreSame(dialogFocus, driver.App.FocusedElement);
120+
121+
dialog.Close();
122+
driver.Tick();
123+
124+
Assert.AreSame(focusedBefore, driver.App.FocusedElement);
125+
}
126+
}

src/XenoAtom.Terminal.UI.Tests/TextAreaSearchReplaceTests.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,27 @@ public void TextArea_Closing_Find_Popup_Clears_Search_Query()
132132
Assert.AreEqual("No search", target.GetStatusText(), "Closing the find popup should clear match highlighting.");
133133
}
134134

135+
[TestMethod]
136+
public void TextArea_SearchReplace_Popup_Restores_Focus_After_Mode_Toggle_And_Close()
137+
{
138+
var editor = new TextArea("foo bar foo");
139+
using var driver = new TerminalAppTestDriver(editor, TerminalHostKind.Fullscreen, new TerminalSize(60, 10));
140+
driver.Tick();
141+
142+
Assert.AreSame(editor, driver.App.FocusedElement);
143+
144+
driver.Backend.PushEvent(new TerminalKeyEvent { Key = TerminalKey.Unknown, Char = TerminalChar.CtrlF, Modifiers = TerminalModifiers.Ctrl });
145+
driver.Tick();
146+
147+
driver.Backend.PushEvent(new TerminalKeyEvent { Key = TerminalKey.Unknown, Char = TerminalChar.CtrlH, Modifiers = TerminalModifiers.Ctrl });
148+
driver.Tick();
149+
150+
driver.Backend.PushEvent(new TerminalKeyEvent { Key = TerminalKey.Escape });
151+
driver.Tick();
152+
153+
Assert.AreSame(editor, driver.App.FocusedElement);
154+
}
155+
135156
[TestMethod]
136157
public void TextArea_ReplaceAll_Updates_Document_Text()
137158
{

src/XenoAtom.Terminal.UI/Controls/CommandPalette.cs

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ public void Show()
138138
EnsureHostGeometry(app.Root.Bounds, style);
139139
InvalidateResults();
140140

141-
app.ShowWindow(_hostDialog);
141+
_hostDialog.Show();
142142
app.Post(RealignHostDialog);
143143
app.Post(FocusSearch);
144144
}
@@ -153,16 +153,24 @@ public void Close()
153153
return;
154154
}
155155

156+
if (_hostDialog.Parent is null)
157+
{
158+
_hostDialog.Content = null;
159+
_hostGeometryInitialized = false;
160+
_focusContext = null;
161+
return;
162+
}
163+
156164
var app = App ?? _hostDialog.App ?? Dispatcher.AttachedApp;
157-
if (app is null || _hostDialog.Parent is null)
165+
if (app is null)
158166
{
159167
return;
160168
}
161169

162-
app.CloseWindow(_hostDialog);
170+
_hostDialog.Close();
163171
_hostDialog?.Content = null;
164172
_hostGeometryInitialized = false;
165-
RestoreFocus();
173+
_focusContext = null;
166174
}
167175

168176
/// <inheritdoc />
@@ -319,22 +327,6 @@ private void RealignHostDialog()
319327
_hostDialog.Arrange(app.Root.Bounds);
320328
}
321329

322-
private void RestoreFocus()
323-
{
324-
var app = App ?? _hostDialog?.App ?? Dispatcher.AttachedApp;
325-
if (app is null)
326-
{
327-
return;
328-
}
329-
330-
if (_focusContext is not null && ReferenceEquals(_focusContext.App, app))
331-
{
332-
app.Focus(_focusContext);
333-
}
334-
335-
_focusContext = null;
336-
}
337-
338330
private bool IsAttachedToHostDialog()
339331
{
340332
if (_hostDialog is null || _hostDialog.Parent is null)

src/XenoAtom.Terminal.UI/Controls/ContextMenuService.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -552,7 +552,6 @@ private void CloseSelf()
552552
if (popup is not null)
553553
{
554554
popup.Close();
555-
_rootPopup.App?.Focus(_parent);
556555
}
557556
}
558557

0 commit comments

Comments
 (0)