Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 3 additions & 14 deletions src/OpenClaw.Tray.WinUI/Automations/AutomationsRoot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using OpenClaw.Shared.Cron;
using OpenClawTray.Helpers;
using OpenClawTray.Presentation;
using static Microsoft.UI.Reactor.Factories;
using static OpenClawTray.Automations.AutomationsVisuals;
Expand Down Expand Up @@ -314,11 +315,12 @@ void RegisterRow(FrameworkElement row, string id)
IsOpen = true,
CloseButtonText = t["Cancel"],
DefaultButton = ContentDialogButton.None,
OnOpened = () => { if (confirmDialogRef.Current is { } native) FocusConfirmationCancel(native); },
OnClosed = result => { confirmDialogRef.Current = null; pending.Result.TrySetResult(result == ContentDialogResult.Primary); setConfirm(null); },
}).Set(native =>
{
confirmDialogRef.Current = native;
// Cancel must get initial focus deterministically, not race WinUI's own initial focus.
DialogInitialFocus.FocusCloseButton(native);
ConfigureDialogWidth(native, dialogWidth);
Microsoft.UI.Xaml.Automation.AutomationProperties.SetAutomationId(native, "AutomationsConfirmDialog");
Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(native, pending.Title);
Expand Down Expand Up @@ -384,19 +386,6 @@ private static Element RenderConfirmation(ConfirmRequest request, double width)
.Padding(12).CornerRadius(6).Background(Theme.SubtleFill))
.MaxWidth(420);

private static void FocusConfirmationCancel(ContentDialog sender) =>
sender.DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low,
() => FindCloseButton(sender, sender.CloseButtonText)?.Focus(FocusState.Programmatic));

private static Button? FindCloseButton(DependencyObject parent, string label)
{
if (parent is Button button && (button.Name == "CloseButton" || string.Equals(button.Content as string, label, StringComparison.Ordinal)))
return button;
for (var i = 0; i < Microsoft.UI.Xaml.Media.VisualTreeHelper.GetChildrenCount(parent); i++)
if (FindCloseButton(Microsoft.UI.Xaml.Media.VisualTreeHelper.GetChild(parent, i), label) is { } close)
return close;
return null;
}

private static Element RenderHeader(AutomationsHost host, AutomationsSnapshot state, AutomationsActions actions, AutomationsLook look, PageWidthTier tier)
{
Expand Down
108 changes: 108 additions & 0 deletions src/OpenClaw.Tray.WinUI/Helpers/DialogInitialFocus.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// DialogInitialFocus: makes a ContentDialog's initial keyboard focus land on its Close (Cancel) button.
//
// When a ContentDialog opens, WinUI runs its own initial-focus transaction (with DefaultButton None
// it picks the first focusable element, for example selectable body text). A focus call merely
// queued from Opened can run before or after that transaction, so the outcome depends on timing.
// Redirecting the first GettingFocus into the dialog makes Cancel win every time. When a dialog
// opens right after another closes, Opened and even the first focus can arrive before the Cancel
// button is realized or focusable; the fallback then retries after each layout pass until Cancel takes
// focus. The fallback also covers a dialog that opened before this was attached. Once Cancel has focus,
// later focus moves are untouched.
//
// Usage (Reactor ContentDialog element):
// ContentDialog(...) with { DefaultButton = ContentDialogButton.None, CloseButtonText = "Cancel" }
// .Set(native => DialogInitialFocus.FocusCloseButton(native));
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;

namespace OpenClawTray.Helpers;

internal static class DialogInitialFocus
{
private const string CloseButtonPart = "CloseButton";

private static readonly DependencyProperty PendingProperty = DependencyProperty.RegisterAttached(
"Pending", typeof(object), typeof(DialogInitialFocus), new PropertyMetadata(null));

/// <summary>
/// Attaches once per native dialog and gives its first focus to Cancel. Callers mount a fresh keyed
/// dialog per confirmation (as AutomationsRoot does), so one open per native dialog is covered.
/// </summary>
public static void FocusCloseButton(ContentDialog dialog)
{
if (dialog.GetValue(PendingProperty) is not null)
return;
dialog.SetValue(PendingProperty, true);
dialog.GettingFocus += RedirectInitialFocus;
dialog.Opened += FocusOnOpened;
// Real user input ends the initial-focus duty, so a late fallback never takes focus back after
// the user starts interacting. Handled events count too (a TextBox handles its own keys).
dialog.AddHandler(UIElement.KeyDownEvent, new KeyEventHandler(EndOnUserInput), handledEventsToo: true);
dialog.AddHandler(UIElement.PointerPressedEvent, new PointerEventHandler(EndOnUserInput), handledEventsToo: true);
// A side-mounted dialog can open before the modifier that attaches this runs.
if (dialog.IsLoaded)
QueueFocus(dialog);
}

internal static Button? FindCloseButton(DependencyObject parent)
{
if (parent is Button { Name: CloseButtonPart } button)
return button;
for (var index = 0; index < VisualTreeHelper.GetChildrenCount(parent); index++)
if (FindCloseButton(VisualTreeHelper.GetChild(parent, index)) is { } found)
return found;
return null;
}

private static bool IsPending(ContentDialog dialog) => dialog.GetValue(PendingProperty) is true;

private static void EndOnUserInput(object sender, RoutedEventArgs args) =>
((ContentDialog)sender).SetValue(PendingProperty, false);

private static void RedirectInitialFocus(UIElement sender, GettingFocusEventArgs args)
{
var dialog = (ContentDialog)sender;
// Only the initial transaction (focus entering from outside); never redirect moves within the dialog.
if (!IsPending(dialog) || IsWithin(dialog, args.OldFocusedElement))
return;
if (FindCloseButton(dialog) is not { } cancel || !args.TrySetNewFocusedElement(cancel))
QueueFocus(dialog);
else
dialog.SetValue(PendingProperty, false);
}

private static bool IsWithin(DependencyObject ancestor, DependencyObject? element)
{
for (var current = element; current is not null; current = VisualTreeHelper.GetParent(current))
if (ReferenceEquals(current, ancestor))
return true;
return false;
}

private static void FocusOnOpened(ContentDialog sender, ContentDialogOpenedEventArgs args) => QueueFocus(sender);

// Runs after WinUI's own initial-focus transaction; a no-op once Cancel has received focus.
private static void QueueFocus(ContentDialog dialog) =>
dialog.DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, () => FocusWhenRealized(dialog));

private static void FocusWhenRealized(ContentDialog dialog)
{
if (!IsPending(dialog) || !dialog.IsLoaded)
return;
if (FindCloseButton(dialog) is { } cancel && cancel.Focus(FocusState.Programmatic))
{
dialog.SetValue(PendingProperty, false);
return;
}
// Cancel is not realized or not focusable yet: try again after the next layout pass.
void OnLayoutUpdated(object? sender, object args)
{
dialog.LayoutUpdated -= OnLayoutUpdated;
QueueFocus(dialog);
}
dialog.LayoutUpdated += OnLayoutUpdated;
}
}
15 changes: 10 additions & 5 deletions tests/OpenClaw.Tray.UITests/AutomationsUiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -680,10 +680,12 @@ await WaitUiAsync(run, () => FindById(run, "AutomationsConfirmDialog") is not nu
Assert.Equal($"It's removed from Fixture Gateway ({run.Profile.RunId[..8]}) along with its schedule and run history. This can't be undone.",
removeText.DocumentRange.GetText(-1).TrimEnd('\r', '\n'));
var removeDialog = FindById(run, "AutomationsConfirmDialog")!;
var cancel = removeDialog.FindFirst(TreeScope.Descendants, new AndCondition(
new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Button),
new PropertyCondition(AutomationElement.NameProperty, "Cancel")));
await WaitUiAsync(run, () => cancel.Current.HasKeyboardFocus, "Cancel receives destructive initial focus");
// Re-acquire Cancel on every poll: a dialog re-render can replace the button's peer.
await WaitUiAsync(run, () => FindById(run, "AutomationsConfirmDialog")?.FindFirst(TreeScope.Descendants,
new AndCondition(
new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Button),
new PropertyCondition(AutomationElement.NameProperty, "Cancel"))) is { Current.HasKeyboardFocus: true },
"Cancel receives destructive initial focus");
Assert.Equal(ControlType.Window, removeDialog.Current.ControlType);
Assert.True(((WindowPattern)removeDialog.GetCurrentPattern(WindowPattern.Pattern)).Current.IsModal);
await ScanStateAsync("remove confirmation");
Expand Down Expand Up @@ -1564,8 +1566,11 @@ private static AutomationElement PageScrollPeer(GatewayFixtureRun run, bool dump
var marker = window.FindFirst(TreeScope.Descendants,
new PropertyCondition(AutomationElement.AutomationIdProperty, "CronPageMarker"));
if (marker is null) return null;
Assert.True(NativeUiOwnership.IsOwnedDescendant(window, marker, run.AppProcessId),
// Polled while Automations re-renders: a marker peer that is detached or replaced proves nothing yet.
var markerEvidence = NativeUiOwnership.Classify(window, marker, run.AppProcessId);
Assert.True(markerEvidence != NativeUiOwnership.Evidence.Foreign,
"Automations page marker must belong to the retained native window.");
if (markerEvidence != NativeUiOwnership.Evidence.Owned) return null;
var markerIdentity = marker.GetRuntimeId();
bool IsMarker(AutomationElement? element) => element is not null
&& NativeUiOwnership.IsOwnedDescendant(window, element, run.AppProcessId)
Expand Down
181 changes: 181 additions & 0 deletions tests/OpenClaw.Tray.UITests/DialogInitialFocusTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
// Proves DialogInitialFocus gives a destructive ContentDialog's initial focus to Cancel even when the
// dialog content holds a focusable element: the first focus inside the dialog after Cancel exists is
// Cancel, and nothing else takes focus after it. Opened can fire before the template is realized, so
// focus is observed through the dialog's own GettingFocus, not sampled at Opened. A later focus move is
// never stolen back. Like AutomationsRoot, each confirmation mounts a fresh keyed dialog.
// Run with the DialogInitialFocusTests filter on an isolated Windows desktop.
using Microsoft.UI.Reactor;
using Microsoft.UI.Reactor.Core;
using Microsoft.UI.Reactor.Hosting;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using OpenClawTray.Helpers;
using static Microsoft.UI.Reactor.Factories;

namespace OpenClaw.Tray.UITests;

[Collection(UICollection.Name)]
public sealed class DialogInitialFocusTests(UIThreadFixture ui)
{
private static readonly TimeSpan DialogTransitionTimeout = TimeSpan.FromSeconds(10);
// Each run opens this many confirmations, each right after the previous one closes.
private const int Confirmations = 11;

[Fact]
public async Task DestructiveDialog_CancelOwnsInitialFocusForEachConfirmationWithoutStealingLaterFocus()
{
await ui.ResetContainerAsync();
ReactorHostControl? host = null;
Microsoft.UI.Xaml.Controls.ContentDialog? native = null;
Element? current = null;
var opened = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var closed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
// Each focus change routed through the dialog, read synchronously in GettingFocus after the helper's
// handler (so the final target after any redirect), with whether Cancel existed at that moment.
var focusLog = new List<(DependencyObject Element, bool CancelRealized)>();
var recorded = new HashSet<Microsoft.UI.Xaml.Controls.ContentDialog>(ReferenceEqualityComparer.Instance);
void RecordFocus(UIElement sender, GettingFocusEventArgs args)
{
if (!args.Cancel && args.NewFocusedElement is { } element)
focusLog.Add((element, DialogInitialFocus.FindCloseButton(sender) is not null));
}
void AssertCancelKeptFocus(Microsoft.UI.Xaml.Controls.ContentDialog dialog, string key)
{
var cancel = DialogInitialFocus.FindCloseButton(dialog)!;
Assert.Same(cancel, FocusManager.GetFocusedElement(dialog.XamlRoot));
var inside = focusLog.Where(entry => IsWithin(dialog, entry.Element)).ToArray();
// Before Cancel exists nothing can redirect to it; once it exists, the first focus must be Cancel.
var firstRealized = inside.FirstOrDefault(entry => entry.CancelRealized).Element;
Assert.True(ReferenceEquals(firstRealized, cancel),
$"{key}: first focus after Cancel was realized went to {firstRealized?.GetType().Name ?? "nothing"}.");
// WinUI can raise GettingFocus again for the same target; only a different element is a move.
var afterCancel = inside.SkipWhile(entry => !ReferenceEquals(entry.Element, cancel))
.Where(entry => !ReferenceEquals(entry.Element, cancel)).ToArray();
Assert.True(afterCancel.Length == 0,
$"{key}: focus moved inside the dialog after Cancel: {string.Join(", ", afterCancel.Select(entry => entry.Element.GetType().Name))}");
}
Element Dialog(string key) => (ContentDialog("", TextBox("selectable body", _ => { }).AutomationName("Body"), "Remove") with
{
IsOpen = true,
CloseButtonText = "Cancel",
DefaultButton = ContentDialogButton.None,
OnClosed = _ => closed.TrySetResult(),
OnOpened = () => opened.TrySetResult(),
}).Set(dialog =>
{
native = dialog;
DialogInitialFocus.FocusCloseButton(dialog);
// After the helper's handler, so the recorder sees the final target. OnMountAdd would receive
// Reactor's placeholder, not this native dialog.
AttachRecorder(dialog);
}).WithKey(key);
void AttachRecorder(Microsoft.UI.Xaml.Controls.ContentDialog dialog)
{
if (recorded.Add(dialog))
dialog.GettingFocus += RecordFocus;
}
void Render(Element next)
{
host!.Content = current is null
? host.Reconciler.Mount(next, static () => { })
: host.Reconciler.Reconcile(current, next, (UIElement)host.Content!, static () => { });
current = next;
}
async Task<Microsoft.UI.Xaml.Controls.ContentDialog> ConfirmAndAssertCancelAsync(string key)
{
opened = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
closed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
focusLog.Clear();
await ui.RunOnUIAsync(() => Render(Dialog(key)));
await opened.Task.WaitAsync(DialogTransitionTimeout);
return await ui.RunOnUIAsync(async () =>
{
var dialog = native!;
// Opened can precede the template and the first focus. Wait until Cancel holds focus, drain
// queued focus work, then require that Cancel was the first focus once it existed and kept it.
await TestSupport.WaitForRenderedConditionAsync(() =>
DialogInitialFocus.FindCloseButton(dialog) is { } realized && dialog.XamlRoot is { } root &&
ReferenceEquals(FocusManager.GetFocusedElement(root), realized),
$"{key}: Cancel receives initial focus in the realized dialog",
() => $"focused={FocusManager.GetFocusedElement(dialog.XamlRoot)?.GetType().Name ?? "none"}");
await ui.YieldToRenderAsync();
AssertCancelKeptFocus(dialog, key);
return dialog;
});
}
async Task CloseAndRemoveAsync()
{
await ui.RunOnUIAsync(() => native!.Hide());
await closed.Task.WaitAsync(DialogTransitionTimeout);
await ui.RunOnUIAsync(() => Render(Empty()));
}
try
{
await ui.RunOnUIAsync(() =>
{
host = new ReactorHostControl { Width = 560, Height = 400 };
ui.Container.Children.Add(host);
});
var first = await ConfirmAndAssertCancelAsync("confirmation-0");
// Once Cancel has focus the initial-focus duty is over: a later move (here to the body, which
// also runs layout for any pending retry) must not be taken back.
TextBox? body = null;
await ui.RunOnUIAsync(() =>
{
body = FindDescendant<TextBox>(first);
Assert.NotNull(body);
Assert.True(body.Focus(FocusState.Programmatic));
});
await ui.YieldToRenderAsync();
await ui.RunOnUIAsync(() => Assert.Same(body, FocusManager.GetFocusedElement(first.XamlRoot)));

var previous = first;
for (var index = 1; index < Confirmations; index++)
{
if (index > 1)
await ui.RunOnUIAsync(() => AssertCancelKeptFocus(previous, $"confirmation-{index - 1} before close"));
await CloseAndRemoveAsync();
var next = await ConfirmAndAssertCancelAsync($"confirmation-{index}");
Assert.NotSame(previous, next);
previous = next;
}
// The last confirmation stays open through the test's own async steps; Cancel must still hold focus.
await ui.RunOnUIAsync(() => AssertCancelKeptFocus(previous, $"confirmation-{Confirmations - 1} before close"));
await CloseAndRemoveAsync();
}
finally
{
await ui.RunOnUIAsync(() =>
{
foreach (var dialog in recorded)
dialog.GettingFocus -= RecordFocus;
if (current is not null && native is { IsLoaded: true })
native.Hide();
if (host is null) return;
ui.Container.Children.Remove(host);
host.Content = null;
host.Dispose();
});
}
}

private static bool IsWithin(DependencyObject ancestor, DependencyObject element)
{
for (DependencyObject? current = element; current is not null; current = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetParent(current))
if (ReferenceEquals(current, ancestor))
return true;
return false;
}

private static T? FindDescendant<T>(DependencyObject parent) where T : DependencyObject
{
for (var index = 0; index < Microsoft.UI.Xaml.Media.VisualTreeHelper.GetChildrenCount(parent); index++)
{
var child = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetChild(parent, index);
if (child is T match) return match;
if (FindDescendant<T>(child) is { } nested) return nested;
}
return null;
}
}
Loading
Loading