diff --git a/src/OpenClaw.Tray.WinUI/App.xaml b/src/OpenClaw.Tray.WinUI/App.xaml index f0fa5ca03..97ec85d3d 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml +++ b/src/OpenClaw.Tray.WinUI/App.xaml @@ -34,6 +34,12 @@ + + + + + 1 + False @@ -74,6 +86,12 @@ + + + + + 2 + True @@ -83,6 +101,20 @@ + + + + diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs index 4496d3cc9..3ec90caf1 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawChatTimeline.cs @@ -207,121 +207,6 @@ private static FontFamily s_chatTextFontFamily } } - // Per-DispatcherQueue selection-highlight brushes for the user - // bubble. The bubble background is the user's chosen system accent - // (which may be red, green, purple, …), so a hardcoded color would - // clash whenever the accent is non-blue. SystemAccentColorDark2 is - // the OS-defined "darker shade of the current accent" — guaranteed - // darker than the bubble's AccentFillColorDefault background and - // high-contrast against the bubble's white foreground for every - // accent. In High Contrast the bubble switches to - // SystemColorHighlight (often near-black), so we fall back to the - // OS-guaranteed SystemColorHighlightColor for the band there. - // - // SolidColorBrush is a DependencyObject with thread affinity, so a - // single static instance would crash with RPC_E_WRONG_THREAD if a - // second window on a different dispatcher ever tried to use it. - // Keying by DispatcherQueue keeps one shared brush per window while - // still avoiding per-render allocation. ConditionalWeakTable lets a - // closing window's brush be collected with its dispatcher. The - // brush's Color is mutated in place when the source color changes - // (e.g. user switches their accent in Windows Settings) so - // already-rendered TextBlocks update atomically. - private static readonly System.Runtime.CompilerServices.ConditionalWeakTable< - Microsoft.UI.Dispatching.DispatcherQueue, SolidColorBrush> s_accentDarkByDispatcher = new(); - private static readonly System.Runtime.CompilerServices.ConditionalWeakTable< - Microsoft.UI.Dispatching.DispatcherQueue, SolidColorBrush> s_hcHighlightByDispatcher = new(); - // AccessibilitySettings is a WinRT object with DispatcherQueue - // affinity: an instance created on one dispatcher cannot reliably - // be read from another. We deliberately avoid Lazy<>: Lazy - // permanently caches the factory's result, so a single failed - // construction would cache null forever and silently disable the - // High Contrast code path. Per-dispatcher cache keyed by - // ConditionalWeakTable lets each window have its own instance, - // collected when its dispatcher dies. On any thrown exception we - // drop the cached instance so the next render retries from scratch. - private static readonly System.Runtime.CompilerServices.ConditionalWeakTable< - Microsoft.UI.Dispatching.DispatcherQueue, - global::Windows.UI.ViewManagement.AccessibilitySettings> s_a11yByDispatcher = new(); - - private static bool TryDetectHighContrast() - { - var dispatcher = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread(); - if (dispatcher is null) - { - // Off-thread caller (tests, design-time). One-shot, no caching. - try { return new global::Windows.UI.ViewManagement.AccessibilitySettings().HighContrast; } - catch { return false; } - } - if (!s_a11yByDispatcher.TryGetValue(dispatcher, out var settings)) - { - try - { - settings = new global::Windows.UI.ViewManagement.AccessibilitySettings(); - s_a11yByDispatcher.Add(dispatcher, settings); - } - catch { return false; } - } - try { return settings.HighContrast; } - catch - { - // Drop the cached instance so the next call retries. - s_a11yByDispatcher.Remove(dispatcher); - return false; - } - } - - private static SolidColorBrush GetUserBubbleSelectionBrush(bool isHighContrast) - { - var dispatcher = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread(); - var table = isHighContrast ? s_hcHighlightByDispatcher : s_accentDarkByDispatcher; - var color = isHighContrast - ? TryGetThemeColor("SystemColorHighlightColor", Microsoft.UI.Colors.Blue) - : TryGetThemeColor("SystemAccentColorDark2", Microsoft.UI.Colors.DarkBlue); - - // No dispatcher means we're being called off-thread (e.g. - // from a unit test). Allocate a one-shot brush — it can't be - // safely cached without a dispatcher to key it on. - if (dispatcher is null) - return new SolidColorBrush(color); - - if (!table.TryGetValue(dispatcher, out var brush)) - { - brush = new SolidColorBrush(color); - table.Add(dispatcher, brush); - } - else if (brush.Color != color) - { - // Mutate in place rather than reallocating: TextBlocks - // rendered earlier hold a reference to this brush, so - // updating .Color updates them atomically without waiting - // for the next render pass. - brush.Color = color; - } - return brush; - } - - private static Color TryGetThemeColor(string key, Color fallback) - { - try - { - var app = Application.Current; - if (app is null) return fallback; - if (app.Resources.TryGetValue(key, out var v)) - { - // Theme dictionaries usually store Color, but a custom - // theme override can supply a SolidColorBrush under the - // same key. Accept either rather than silently falling - // back to DarkBlue / Blue when the resource is present - // but wrapped in a brush. - if (v is Color c) return c; - if (v is SolidColorBrush brush) return brush.Color; - } - } - catch (Exception ex) { OpenClawTray.Services.Logger.Debug($"ChatTimeline: resource brush lookup failed (unpackaged/test host?): {ex.Message}"); } - return fallback; - } - private static void ApplyPlainSelectableInlines(TextBlock textBlock, string? text) { var normalized = text ?? string.Empty; @@ -1020,12 +905,7 @@ static Element TimelineInset(Element child, double top = 2, double bottom = 2) = // the bubble surface below is opaque (Mica/acrylic isn't being // used directly), so the LayerOnAcrylic family would render // incorrectly in dark/HC themes. - var toolCardBgBrush = themeBrush("CardBackgroundFillColorDefaultBrush"); var toolCardBorderBrush = themeBrush("ControlStrokeColorDefaultBrush"); - // High-contrast themes need a thicker border to render at all - // (WinUI guidance: 2px minimum). Detect once at render time so the - // tool card border stays visible when HC is on, normal 1px otherwise. - double toolCardBorderThickness = TryDetectHighContrast() ? 2 : 1; // Avatar: 36×36 circle (Kenny uses circular avatars). Same constructor // as before but radius defaults to half the size for a perfect circle. @@ -1409,12 +1289,6 @@ Element RenderUserEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst var entryMeta = MetaFor(entry.Id); if (hasMessage) { - // Resolve HC + selection brush once per render method call - // rather than per Set-lambda re-run. HC state cannot change - // mid-render, and the brush is cached per-dispatcher so - // every user bubble in this render shares the same instance. - bool isHighContrast = TryDetectHighContrast(); - var selectionHighlightBrush = GetUserBubbleSelectionBrush(isHighContrast); bubbleChildren.Add( RichTextBlock() .Set(t => @@ -1431,23 +1305,7 @@ Element RenderUserEntry(ChatTimelineItem entry, bool startsBurst, bool endsBurst t.Width = double.NaN; t.MinWidth = 0; t.MaxWidth = double.PositiveInfinity; - // The default SelectionHighlightColor is the - // system accent — which equals the user bubble's - // background — so the highlight band is invisible - // against the bubble, and WinUI does NOT auto- - // invert an explicitly-set Foreground for - // selected glyphs. Outside High Contrast, use a - // darker shade of the current accent - // (SystemAccentColorDark2) so the band tracks - // whichever accent the user picked while keeping - // the white foreground readable. In High Contrast - // the bubble background switches to - // SystemColorHighlight (often near-black), where - // an accent-derived band may drop below WCAG - // 3:1, so fall back to the system selection - // color the OS guarantees contrasts with both - // surfaces. - t.SelectionHighlightColor = selectionHighlightBrush; + t.Style = (Style)Application.Current.Resources["ChatUserBubbleSelectionStyle"]; // Render the message as a single Paragraph (one Run) // so the whole user message is one continuous // selection scope — matching the assistant bubble's @@ -1974,10 +1832,9 @@ Element PhantomChevron() => Caption("▸") ToolName: null, ToolResult: aggregateStatus, ToolOutput: null)); Element CardOf(Element[] rowEls) => Border(VStack(0, rowEls)) - .Background(toolCardBgBrush) - .WithBorder(toolCardBorderBrush, toolCardBorderThickness) .Set(b => { + b.Style = (Style)Application.Current.Resources["ChatToolCardBorderStyle"]; // CornerRadius is uniform across the card; setting it // directly works because rounding nests under the // Border's BorderThickness. @@ -2349,8 +2206,6 @@ Element RenderCompactionEntry(ChatTimelineItem entry) LocalizationHelper.GetString("Chat_Compaction_Title"), LocalizationHelper.GetString("Chat_Compaction_MetricsFormat"), LocalizationHelper.GetString("Chat_Compaction_FallbackDetail")); - var borderThickness = TryDetectHighContrast() ? 2 : 1; - return TimelineInset( Border( VStack(3, @@ -2368,8 +2223,7 @@ Element RenderCompactionEntry(ChatTimelineItem entry) t.TextAlignment = TextAlignment.Center; }).Foreground(themeBrush("TextFillColorSecondaryBrush")) ) - ).Background(themeBrush("CardBackgroundFillColorDefaultBrush")) - .WithBorder(themeBrush("ControlStrokeColorDefaultBrush"), borderThickness) + ).Set(b => b.Style = (Style)Application.Current.Resources["ChatCompactionCardStyle"]) .CornerRadius(8) .Padding(16, 10, 16, 10) .HAlign(HorizontalAlignment.Stretch) diff --git a/src/OpenClaw.Tray.WinUI/Chat/OpenClawReactorChatRoot.cs b/src/OpenClaw.Tray.WinUI/Chat/OpenClawReactorChatRoot.cs index 06fbb94a4..0d7eed6a7 100644 --- a/src/OpenClaw.Tray.WinUI/Chat/OpenClawReactorChatRoot.cs +++ b/src/OpenClaw.Tray.WinUI/Chat/OpenClawReactorChatRoot.cs @@ -1,6 +1,7 @@ using Microsoft.UI; using Microsoft.UI.Reactor; using Microsoft.UI.Reactor.Core; +using Microsoft.UI.Reactor.Hosting; using Microsoft.UI.Reactor.Input; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; @@ -582,184 +583,11 @@ public sealed record ReactorChatComposerProps( public sealed class ReactorChatComposer : Component { private static readonly string[] ThinkingLevels = ["off", "minimal", "low", "medium", "high"]; - private static readonly ConditionalWeakTable ThemeCallbacks = new(); - private static readonly global::Windows.UI.ViewManagement.AccessibilitySettings? AccessibilitySettings = - CreateAccessibilitySettings(); - - private sealed class ThemeCallbackState(Action apply) - { - public Action Apply { get; set; } = apply; - public global::Windows.Foundation.TypedEventHandler< - global::Windows.UI.ViewManagement.AccessibilitySettings, - object>? HighContrastChanged { get; set; } - public bool HighContrastEventUnavailable { get; set; } - } - - private static void ApplyTheme(FrameworkElement control, Action apply) - { - apply(); - if (ThemeCallbacks.TryGetValue(control, out var state)) - { - state.Apply = apply; - EnsureHighContrastCallback(control, state); - return; - } - - state = new ThemeCallbackState(apply); - ThemeCallbacks.Add(control, state); - control.ActualThemeChanged += static (sender, _) => - { - if (sender is FrameworkElement element - && ThemeCallbacks.TryGetValue(element, out var callback)) - callback.Apply(); - }; - control.Loaded += static (sender, _) => - { - if (sender is FrameworkElement element - && ThemeCallbacks.TryGetValue(element, out var callback)) - { - callback.Apply(); - EnsureHighContrastCallback(element, callback); - } - }; - control.Unloaded += static (sender, _) => - { - if (sender is FrameworkElement element - && ThemeCallbacks.TryGetValue(element, out var callback) - && callback.HighContrastChanged is { } handler - && AccessibilitySettings is { } accessibilitySettings) - { - try - { - accessibilitySettings.HighContrastChanged -= handler; - } - catch (System.Runtime.InteropServices.COMException) - { - // The optional WinRT event source can be unavailable while a view tears down. - } - callback.HighContrastChanged = null; - } - }; - EnsureHighContrastCallback(control, state); - } - - private static global::Windows.UI.ViewManagement.AccessibilitySettings? CreateAccessibilitySettings() - { - try - { - return new global::Windows.UI.ViewManagement.AccessibilitySettings(); - } - catch - { - return null; - } - } - - private static void EnsureHighContrastCallback( - FrameworkElement control, - ThemeCallbackState state) - { - if (AccessibilitySettings is null - || state.HighContrastChanged is not null - || state.HighContrastEventUnavailable) - return; - - global::Windows.Foundation.TypedEventHandler< - global::Windows.UI.ViewManagement.AccessibilitySettings, - object> handler = (_, _) => - { - control.DispatcherQueue?.TryEnqueue(() => - { - if (ThemeCallbacks.TryGetValue(control, out var callback)) - callback.Apply(); - }); - }; - try - { - AccessibilitySettings.HighContrastChanged += handler; - state.HighContrastChanged = handler; - } - catch (System.Runtime.InteropServices.COMException ex) - { - state.HighContrastEventUnavailable = true; - OpenClawTray.Services.Logger.Warn( - $"[ReactorChatComposer] High Contrast change notifications are unavailable: {ex.Message}"); - } - } - - private static Brush ResolveThemeBrush(string resourceKey, ElementTheme theme) - { - if (FindThemedResource(resourceKey, theme) is Brush themed) - return themed; - if (Application.Current?.Resources.TryGetValue(resourceKey, out var value) == true - && value is Brush brush) - return brush; - return new SolidColorBrush(Microsoft.UI.Colors.Transparent); - } - - private static object? FindThemedResource(string resourceKey, ElementTheme theme) - { - if (Application.Current?.Resources is not { } root) - return null; - - var themeNames = IsHighContrast() - ? new[] { "HighContrast" } - : theme switch - { - ElementTheme.Dark => ["Dark", "Default"], - ElementTheme.Light => ["Light"], - _ => Array.Empty(), - }; - return themeNames - .Select(themeName => SearchThemeDictionaries(root, resourceKey, themeName, 0)) - .FirstOrDefault(value => value is not null); - } - - private static bool IsHighContrast() - { - return AccessibilitySettings?.HighContrast ?? false; - } - - private static object? SearchThemeDictionaries( - ResourceDictionary dictionary, - string resourceKey, - string themeName, - int depth) - { - if (depth > 6) - return null; - - if (dictionary.ThemeDictionaries.TryGetValue(themeName, out var entry) - && entry is ResourceDictionary themed - && LookupResource(themed, resourceKey) is { } value) - return value; - - foreach (var merged in dictionary.MergedDictionaries) - { - if (SearchThemeDictionaries(merged, resourceKey, themeName, depth + 1) is { } found) - return found; - } - - return null; - } - - private static object? LookupResource(ResourceDictionary dictionary, string resourceKey) - { - if (dictionary.TryGetValue(resourceKey, out var value)) - return value; - - foreach (var merged in dictionary.MergedDictionaries) - { - if (LookupResource(merged, resourceKey) is { } found) - return found; - } - - return null; - } public override Element Render() { var props = Props; + var colorScheme = UseColorScheme(); var (text, setText) = UseState(string.Empty, threadSafe: true); var (isSending, setIsSending) = UseState(false, threadSafe: true); var (isRecording, setIsRecording) = UseState(false, threadSafe: true); @@ -903,26 +731,6 @@ void Send() : Localized("Chat_Composer_Tooltip_Send", "Send"); var controlCornerRadius = new CornerRadius(4); - void ApplySubtleButtonStyle(Button button) - { - var transparent = new SolidColorBrush(Microsoft.UI.Colors.Transparent); - button.Background = transparent; - button.BorderBrush = transparent; - button.BorderThickness = new Thickness(0); - button.Resources["ButtonBackground"] = transparent; - button.Resources["ButtonBorderBrush"] = transparent; - button.Resources["ButtonBorderBrushPointerOver"] = transparent; - button.Resources["ButtonBorderBrushPressed"] = transparent; - ApplyTheme(button, () => - { - button.Foreground = ResolveThemeBrush("TextFillColorSecondaryBrush", button.ActualTheme); - button.Resources["ButtonBackgroundPointerOver"] = - ResolveThemeBrush("SubtleFillColorSecondaryBrush", button.ActualTheme); - button.Resources["ButtonBackgroundPressed"] = - ResolveThemeBrush("SubtleFillColorTertiaryBrush", button.ActualTheme); - }); - } - Element IconButton(string glyph, string automationName, Action onClick, bool enabled = true) { return Button( @@ -933,6 +741,14 @@ Element IconButton(string glyph, string automationName, Action onClick, bool ena }), onClick) .AutomationName(automationName) + .Foreground(Theme.SecondaryText) + .Resources(resources => resources + .Set("ButtonBackground", Theme.Ref("SubtleFillColorTransparentBrush")) + .Set("ButtonBackgroundPointerOver", Theme.SubtleFill) + .Set("ButtonBackgroundPressed", Theme.Ref("SubtleFillColorTertiaryBrush")) + .Set("ButtonBorderBrush", Theme.Ref("SubtleFillColorTransparentBrush")) + .Set("ButtonBorderBrushPointerOver", Theme.Ref("SubtleFillColorTransparentBrush")) + .Set("ButtonBorderBrushPressed", Theme.Ref("SubtleFillColorTransparentBrush"))) .Set(button => { button.Width = 32; @@ -942,7 +758,7 @@ Element IconButton(string glyph, string automationName, Action onClick, bool ena button.Padding = new Thickness(0); button.CornerRadius = controlCornerRadius; button.IsEnabled = enabled; - ApplySubtleButtonStyle(button); + button.BorderThickness = new Thickness(0); ToolTipService.SetToolTip(button, automationName); }); } @@ -967,6 +783,14 @@ Element PickerButton(string label, string automationName, bool enabled, double m })), () => { }) .AutomationName(automationName) + .Foreground(Theme.SecondaryText) + .Resources(resources => resources + .Set("ButtonBackground", Theme.Ref("SubtleFillColorTransparentBrush")) + .Set("ButtonBackgroundPointerOver", Theme.SubtleFill) + .Set("ButtonBackgroundPressed", Theme.Ref("SubtleFillColorTertiaryBrush")) + .Set("ButtonBorderBrush", Theme.Ref("SubtleFillColorTransparentBrush")) + .Set("ButtonBorderBrushPointerOver", Theme.Ref("SubtleFillColorTransparentBrush")) + .Set("ButtonBorderBrushPressed", Theme.Ref("SubtleFillColorTransparentBrush"))) .Set(button => { button.Height = 32; @@ -975,7 +799,7 @@ Element PickerButton(string label, string automationName, bool enabled, double m button.Padding = new Thickness(8, 0, 8, 0); button.CornerRadius = controlCornerRadius; button.IsEnabled = enabled; - ApplySubtleButtonStyle(button); + button.BorderThickness = new Thickness(0); }); } @@ -999,8 +823,7 @@ Element PickerButton(string label, string automationName, bool enabled, double m .Height(2 + (audioLevel * (index % 3 == 1 ? 10 : 7))) .CornerRadius(1) .VAlign(VerticalAlignment.Center) - .Set(border => ApplyTheme(border, () => border.Background = - ResolveThemeBrush("TextFillColorSecondaryBrush", border.ActualTheme)))) + .Background(Theme.SecondaryText)) .ToArray(); Element voiceFeedback = !isRecording ? Empty() @@ -1011,12 +834,10 @@ Element PickerButton(string label, string automationName, bool enabled, double m .Width(6) .Height(6) .CornerRadius(3) - .Set(border => ApplyTheme(border, () => border.Background = - ResolveThemeBrush("TextFillColorSecondaryBrush", border.ActualTheme))), + .Background(Theme.SecondaryText), TextBlock(voiceFeedbackText) .FontSize(11) - .Set(textBlock => ApplyTheme(textBlock, () => textBlock.Foreground = - ResolveThemeBrush("TextFillColorSecondaryBrush", textBlock.ActualTheme))), + .Foreground(Theme.SecondaryText), HStack(1, waveformBars))) .Padding(8, 4) .HAlign(HorizontalAlignment.Left); @@ -1135,7 +956,8 @@ void CommitSlashText(string value, ReactorSlashMenuState nextState) slashDisplay.Query, slashDisplay.SelectedIndex, slashDisplay.SelectableCount, - popupCatalogKey); + popupCatalogKey, + colorScheme); FrameworkElement? slashPopupContent; if (!slashPopupVisible) { @@ -1148,27 +970,28 @@ void CommitSlashText(string value, ReactorSlashMenuState nextState) } else if (slashDisplay.IsLoading) { - slashPopupContent = BuildSlashHintPopup( - Localized("Chat_Composer_Slash_Loading", "Loading commands...")); + slashPopupContent = CreateSlashPopupHost(BuildSlashHintPopup( + Localized("Chat_Composer_Slash_Loading", "Loading commands..."))); slashPopupContentRef.Current = (popupStateKey, slashPopupContent); } else if (slashDisplay.IsArgsMode && slashDisplay.ArgCommand is { } argCommand) { - slashPopupContent = BuildSlashArgPopup( + slashPopupContent = CreateSlashPopupHost(BuildSlashArgPopup( argCommand, slashDisplay.ArgChoices, slashDisplay.SelectedIndex, choice => CommitSlashText( argCommand.BuildArgInsertionText(choice.Value), - ReactorSlashMenuState.Closed)); + ReactorSlashMenuState.Closed))); slashPopupContentRef.Current = (popupStateKey, slashPopupContent); } else { - slashPopupContent = BuildSlashPopup( + slashPopupContent = CreateSlashPopupHost(BuildSlashPopup( slashDisplay.Groups, slashDisplay.SelectedIndex, slashDisplay.Query, + colorScheme, command => { CommitSlashText( @@ -1176,7 +999,7 @@ void CommitSlashText(string value, ReactorSlashMenuState nextState) command.FirstArgChoices().Count > 0 ? new ReactorSlashMenuState(true, string.Empty, 0, true) : ReactorSlashMenuState.Closed); - }); + })); slashPopupContentRef.Current = (popupStateKey, slashPopupContent); } @@ -1466,11 +1289,8 @@ void CommitSlashText(string value, ReactorSlashMenuState nextState) .BorderThickness(1) .CornerRadius(8) .Margin(12) - .Set(border => ApplyTheme(border, () => - { - border.Background = ResolveThemeBrush("ControlFillColorDefaultBrush", border.ActualTheme); - border.BorderBrush = ResolveThemeBrush("ControlStrokeColorDefaultBrush", border.ActualTheme); - })) + .Background(Theme.ControlFill) + .BorderBrush(Theme.ControlStroke) .HAlign(HorizontalAlignment.Stretch); } @@ -1480,10 +1300,19 @@ private static void CloseSlashPopup(Ref content); + return host; + } + private static void DriveSlashPopup( Ref popupRef, TextBox anchor, @@ -1511,350 +1340,265 @@ private static void DriveSlashPopup( popup.XamlRoot = anchor.XamlRoot; popup.PlacementTarget = anchor; popup.DesiredPlacement = Microsoft.UI.Xaml.Controls.Primitives.PopupPlacementMode.Top; + if (popup.Child is ReactorHostControl previousHost + && !ReferenceEquals(previousHost, content)) + previousHost.Dispose(); popup.Child = content; popup.IsOpen = true; } - private static Border BuildSlashHintPopup(string text) + private static Element BuildSlashHintPopup(string text) { - var label = new TextBlock - { - Text = text, - FontSize = 12, - Margin = new Thickness(8, 6, 8, 6), - }; - ApplyTheme(label, () => label.Foreground = ResolveThemeBrush("TextFillColorSecondaryBrush", label.ActualTheme)); - return SlashShell(label); + return SlashShell( + TextBlock(text) + .FontSize(12) + .Foreground(Theme.SecondaryText) + .Margin(8, 6, 8, 6)); } - private static Border BuildSlashPopup( + private static Element BuildSlashPopup( IReadOnlyList groups, int selectedIndex, string query, + ColorScheme colorScheme, Action onPick) { - var list = new StackPanel { Orientation = Orientation.Vertical }; + var rows = new List(); var index = 0; foreach (var group in groups) { - list.Children.Add(SlashCategoryHeader(CommandCategories.Label(group.Category))); + rows.Add(SlashCategoryHeader(CommandCategories.Label(group.Category))); foreach (var command in group.Commands) { - list.Children.Add(SlashRow(command, index == selectedIndex, query, onPick)); + rows.Add(SlashRow(command, index == selectedIndex, query, colorScheme, onPick)); index++; } } - return SlashShell(new ScrollViewer - { - VerticalScrollBarVisibility = ScrollBarVisibility.Auto, - HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, - MaxHeight = 280, - Content = list, - }); + return SlashShell( + ScrollView(VStack(0, rows.ToArray())) + .MaxHeight(280) + .Set(scrollViewer => + { + scrollViewer.VerticalScrollBarVisibility = ScrollingScrollBarVisibility.Auto; + scrollViewer.HorizontalScrollBarVisibility = ScrollingScrollBarVisibility.Hidden; + })); } - private static TextBlock SlashCategoryHeader(string text) + private static Element SlashCategoryHeader(string text) { - var header = new TextBlock - { - Text = (text ?? string.Empty).ToUpperInvariant(), - FontSize = 11, - FontWeight = Microsoft.UI.Text.FontWeights.Bold, - CharacterSpacing = 60, - Margin = new Thickness(8, 8, 8, 2), - }; - ApplyTheme(header, () => header.Foreground = ResolveThemeBrush("TextFillColorTertiaryBrush", header.ActualTheme)); - return header; + return TextBlock((text ?? string.Empty).ToUpperInvariant()) + .FontSize(11) + .SemiBold() + .CharacterSpacing(60) + .Foreground(Theme.TertiaryText) + .Margin(8, 8, 8, 2); } - private static Border BuildSlashArgPopup( + private static Element BuildSlashArgPopup( GatewayCommand command, IReadOnlyList choices, int selectedIndex, Action onPick) { - var list = new StackPanel { Orientation = Orientation.Vertical }; var argDescription = command.Args?.FirstOrDefault()?.Description; var headerText = !string.IsNullOrWhiteSpace(argDescription) ? $"{command.DisplayName()} {argDescription}" : !string.IsNullOrWhiteSpace(command.Description) ? $"{command.DisplayName()} {command.Description}" : command.DisplayName(); - var header = new TextBlock - { - Text = headerText, - FontSize = 11, - FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, - TextTrimming = TextTrimming.CharacterEllipsis, - MaxLines = 1, - Margin = new Thickness(8, 6, 8, 2), + var rows = new List + { + TextBlock(headerText) + .FontSize(11) + .SemiBold() + .TextTrimming(TextTrimming.CharacterEllipsis) + .MaxLines(1) + .Foreground(Theme.TertiaryText) + .Margin(8, 6, 8, 2), }; - ApplyTheme(header, () => header.Foreground = ResolveThemeBrush("TextFillColorTertiaryBrush", header.ActualTheme)); - list.Children.Add(header); for (var index = 0; index < choices.Count; index++) - list.Children.Add(SlashArgRow(command, choices[index], index == selectedIndex, onPick)); + rows.Add(SlashArgRow(command, choices[index], index == selectedIndex, onPick)); - return SlashShell(new ScrollViewer - { - VerticalScrollBarVisibility = ScrollBarVisibility.Auto, - HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, - MaxHeight = 280, - Content = list, - }); + return SlashShell( + ScrollView(VStack(0, rows.ToArray())) + .MaxHeight(280) + .Set(scrollViewer => + { + scrollViewer.VerticalScrollBarVisibility = ScrollingScrollBarVisibility.Auto; + scrollViewer.HorizontalScrollBarVisibility = ScrollingScrollBarVisibility.Hidden; + })); } - private static Button SlashArgRow( + private static Element SlashArgRow( GatewayCommand command, GatewayCommandArgChoice choice, bool selected, Action onPick) { var label = string.IsNullOrWhiteSpace(choice.Label) ? choice.Value : choice.Label; - var row = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; - var title = new TextBlock - { - Text = label, - FontSize = 13, - FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, - VerticalAlignment = VerticalAlignment.Center, - }; - ApplyTheme(title, () => title.Foreground = ResolveThemeBrush("TextFillColorPrimaryBrush", title.ActualTheme)); - row.Children.Add(title); - - var subtitle = new TextBlock - { - Text = $"{command.DisplayName()} {choice.Value}", - FontSize = 12, - VerticalAlignment = VerticalAlignment.Center, - TextTrimming = TextTrimming.CharacterEllipsis, - MaxLines = 1, - }; - ApplyTheme(subtitle, () => subtitle.Foreground = ResolveThemeBrush("TextFillColorSecondaryBrush", subtitle.ActualTheme)); - row.Children.Add(subtitle); - - var button = new Button - { - Content = row, - Padding = new Thickness(8, 7, 8, 7), - HorizontalAlignment = HorizontalAlignment.Stretch, - HorizontalContentAlignment = HorizontalAlignment.Left, - CornerRadius = new CornerRadius(6), - BorderThickness = new Thickness(0), - }; - ApplyTheme(button, () => - { - button.Background = selected - ? ResolveThemeBrush("SubtleFillColorSecondaryBrush", button.ActualTheme) - : new SolidColorBrush(Microsoft.UI.Colors.Transparent); - button.BorderBrush = new SolidColorBrush(Microsoft.UI.Colors.Transparent); - }); - Microsoft.UI.Xaml.Automation.AutomationProperties.SetName( - button, - $"Choose {label} for {command.DisplayName()}"); - button.Click += (_, _) => onPick(choice); - if (selected) - { - button.Loaded += (_, _) => button.StartBringIntoView( - new BringIntoViewOptions { AnimationDesired = false }); - } - - return button; - } - - private static Border SlashShell(UIElement child) - { - var shell = new Border - { - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(8), - Padding = new Thickness(4), - Child = child, - Shadow = new ThemeShadow(), - Translation = new System.Numerics.Vector3(0, 0, 32), - }; - ApplyTheme(shell, () => - { - shell.Background = ResolvePopupBackgroundBrush(shell.ActualTheme); - shell.BorderBrush = ResolveThemeBrush("SurfaceStrokeColorDefaultBrush", shell.ActualTheme); - }); - return shell; + var background = selected ? Theme.SubtleFill : Theme.Ref("SubtleFillColorTransparentBrush"); + return Button( + HStack( + 8, + TextBlock(label) + .FontSize(13) + .SemiBold() + .VAlign(VerticalAlignment.Center) + .Foreground(Theme.PrimaryText), + TextBlock($"{command.DisplayName()} {choice.Value}") + .FontSize(12) + .VAlign(VerticalAlignment.Center) + .TextTrimming(TextTrimming.CharacterEllipsis) + .MaxLines(1) + .Foreground(Theme.SecondaryText)), + () => onPick(choice)) + .Padding(8, 7, 8, 7) + .HAlign(HorizontalAlignment.Stretch) + .CornerRadius(6) + .AutomationName($"Choose {label} for {command.DisplayName()}") + .Resources(resources => resources + .Set("ButtonBackground", background) + .Set("ButtonBorderBrush", Theme.Ref("SubtleFillColorTransparentBrush"))) + .Set(button => + { + button.HorizontalContentAlignment = HorizontalAlignment.Left; + button.BorderThickness = new Thickness(0); + }) + .OnMount(element => + { + if (selected) + element.StartBringIntoView(new BringIntoViewOptions { AnimationDesired = false }); + }); } - private static Brush ResolvePopupBackgroundBrush(ElementTheme theme) + private static Element SlashShell(Element child) { - var overlay = FindThemedResource("TextControlBackground", theme) as SolidColorBrush - ?? Application.Current?.Resources["TextControlBackground"] as SolidColorBrush; - var baseBrush = FindThemedResource("SolidBackgroundFillColorBaseBrush", theme) as SolidColorBrush - ?? Application.Current?.Resources["SolidBackgroundFillColorBaseBrush"] as SolidColorBrush; - if (overlay is null || baseBrush is null) - return ResolveThemeBrush("SolidBackgroundFillColorBaseBrush", theme); - - var alpha = overlay.Color.A / 255.0; - static byte Mix(byte background, byte foreground, double alpha) => - (byte)Math.Round(background * (1 - alpha) + foreground * alpha); - - return new SolidColorBrush(global::Windows.UI.Color.FromArgb( - 255, - Mix(baseBrush.Color.R, overlay.Color.R, alpha), - Mix(baseBrush.Color.G, overlay.Color.G, alpha), - Mix(baseBrush.Color.B, overlay.Color.B, alpha))); + return Border(child) + .Padding(4) + .CornerRadius(8) + .Background(Theme.Ref("AcrylicBackgroundFillColorDefaultBrush")) + .WithBorder(Theme.Ref("SurfaceStrokeColorFlyoutBrush"), 1) + .Translation(0, 0, 32) + .Set(border => border.Shadow = new ThemeShadow()); } - private static Button SlashRow( + private static Element SlashRow( GatewayCommand command, bool selected, string query, + ColorScheme colorScheme, Action onPick) { - var mono = new FontFamily("Consolas"); - var grid = new Microsoft.UI.Xaml.Controls.Grid - { - ColumnSpacing = 8, - VerticalAlignment = VerticalAlignment.Center, - }; - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - - var icon = new FontIcon - { - FontFamily = (FontFamily)Application.Current.Resources["SymbolThemeFontFamily"], - Glyph = SlashGlyph(command), - FontSize = 14, - VerticalAlignment = VerticalAlignment.Center, + var cells = new List + { + TextBlock(SlashGlyph(command)) + .FontFamily(FluentIconCatalog.SymbolThemeFontFamily) + .FontSize(14) + .VAlign(VerticalAlignment.Center) + .Foreground(Theme.SecondaryText) + .AccessibilityView(Microsoft.UI.Xaml.Automation.Peers.AccessibilityView.Raw) + .Grid(row: 0, column: 0), + TextBlock(command.DisplayName()) + .FontSize(13) + .SemiBold() + .VAlign(VerticalAlignment.Center) + .Foreground(Theme.PrimaryText) + .Set(textBlock => ApplyQueryHighlight(textBlock, query, colorScheme)) + .Grid(row: 0, column: 1), }; - ApplyTheme(icon, () => icon.Foreground = ResolveThemeBrush("TextFillColorSecondaryBrush", icon.ActualTheme)); - Microsoft.UI.Xaml.Automation.AutomationProperties.SetAccessibilityView( - icon, - Microsoft.UI.Xaml.Automation.Peers.AccessibilityView.Raw); - Microsoft.UI.Xaml.Controls.Grid.SetColumn(icon, 0); - grid.Children.Add(icon); - - var name = new TextBlock - { - Text = command.DisplayName(), - FontSize = 13, - FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, - VerticalAlignment = VerticalAlignment.Center, - }; - ApplyTheme(name, () => name.Foreground = ResolveThemeBrush("TextFillColorPrimaryBrush", name.ActualTheme)); - Microsoft.UI.Xaml.Controls.Grid.SetColumn(name, 1); - grid.Children.Add(name); - ApplyQueryHighlight(name, query); - var args = command.ArgTemplate(); if (!string.IsNullOrWhiteSpace(args)) { - var argBlock = new TextBlock - { - Text = args, - FontSize = 12, - FontFamily = mono, - Opacity = 0.75, - VerticalAlignment = VerticalAlignment.Center, - }; - ApplyTheme(argBlock, () => argBlock.Foreground = ResolveThemeBrush("TextFillColorSecondaryBrush", argBlock.ActualTheme)); - Microsoft.UI.Xaml.Controls.Grid.SetColumn(argBlock, 2); - grid.Children.Add(argBlock); + cells.Add( + TextBlock(args) + .FontSize(12) + .FontFamily("Consolas") + .VAlign(VerticalAlignment.Center) + .Foreground(Theme.SecondaryText) + .Grid(row: 0, column: 2)); } if (!string.IsNullOrWhiteSpace(command.Description)) { - var description = new TextBlock - { - Text = command.Description!, - FontSize = 12, - VerticalAlignment = VerticalAlignment.Center, - HorizontalAlignment = HorizontalAlignment.Right, - TextAlignment = TextAlignment.Right, - TextTrimming = TextTrimming.CharacterEllipsis, - MaxLines = 1, - }; - ApplyTheme(description, () => description.Foreground = ResolveThemeBrush("TextFillColorSecondaryBrush", description.ActualTheme)); - Microsoft.UI.Xaml.Controls.Grid.SetColumn(description, 3); - grid.Children.Add(description); - ApplyQueryHighlight(description, query); + cells.Add( + TextBlock(command.Description!) + .FontSize(12) + .VAlign(VerticalAlignment.Center) + .HAlign(HorizontalAlignment.Right) + .TextAlignment(TextAlignment.Right) + .TextTrimming(TextTrimming.CharacterEllipsis) + .MaxLines(1) + .Foreground(Theme.SecondaryText) + .Set(textBlock => ApplyQueryHighlight(textBlock, query, colorScheme)) + .Grid(row: 0, column: 3)); } var options = command.OptionCount(); if (options > 0) { - var badge = SlashBadge($"{options} options"); - Microsoft.UI.Xaml.Controls.Grid.SetColumn(badge, 4); - grid.Children.Add(badge); + cells.Add(SlashBadge($"{options} options").Grid(row: 0, column: 4)); } - var button = new Button - { - Content = grid, - Padding = new Thickness(8, 7, 8, 7), - HorizontalAlignment = HorizontalAlignment.Stretch, - HorizontalContentAlignment = HorizontalAlignment.Stretch, - CornerRadius = new CornerRadius(6), - BorderThickness = new Thickness(0), - }; - ApplyTheme(button, () => - { - button.Background = selected - ? ResolveThemeBrush("SubtleFillColorSecondaryBrush", button.ActualTheme) - : new SolidColorBrush(Microsoft.UI.Colors.Transparent); - button.BorderBrush = new SolidColorBrush(Microsoft.UI.Colors.Transparent); - }); - Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(button, $"Insert {command.DisplayName()}"); - button.Click += (_, _) => onPick(command); - if (selected) - { - button.Loaded += (_, _) => button.StartBringIntoView( - new BringIntoViewOptions { AnimationDesired = false }); - } - return button; + var background = selected ? Theme.SubtleFill : Theme.Ref("SubtleFillColorTransparentBrush"); + return Button( + Grid( + [GridSize.Auto, GridSize.Auto, GridSize.Auto, GridSize.Star(), GridSize.Auto], + [GridSize.Auto], + cells.ToArray()) + .Set(grid => grid.ColumnSpacing = 8) + .VAlign(VerticalAlignment.Center), + () => onPick(command)) + .Padding(8, 7, 8, 7) + .HAlign(HorizontalAlignment.Stretch) + .CornerRadius(6) + .AutomationName($"Insert {command.DisplayName()}") + .Resources(resources => resources + .Set("ButtonBackground", background) + .Set("ButtonBorderBrush", Theme.Ref("SubtleFillColorTransparentBrush"))) + .Set(button => + { + button.HorizontalContentAlignment = HorizontalAlignment.Stretch; + button.BorderThickness = new Thickness(0); + }) + .OnMount(element => + { + if (selected) + element.StartBringIntoView(new BringIntoViewOptions { AnimationDesired = false }); + }); } - private static Border SlashBadge(string text) + private static Element SlashBadge(string text) { - var label = new TextBlock - { - Text = text, - FontSize = 10, - FontWeight = Microsoft.UI.Text.FontWeights.SemiBold, - }; - var badge = new Border - { - Padding = new Thickness(6, 1, 6, 1), - CornerRadius = new CornerRadius(4), - VerticalAlignment = VerticalAlignment.Center, - Child = label, - }; - ApplyTheme(badge, () => - { - var accent = ResolveThemeBrush("AccentFillColorDefaultBrush", badge.ActualTheme); - badge.Background = accent is SolidColorBrush solid - ? new SolidColorBrush(solid.Color) { Opacity = 0.14 } - : accent; - }); - ApplyTheme(label, () => label.Foreground = ResolveThemeBrush("AccentFillColorDefaultBrush", label.ActualTheme)); - return badge; + return Border( + TextBlock(text) + .FontSize(10) + .SemiBold() + .Foreground(Theme.Ref("TextOnAccentFillColorPrimaryBrush"))) + .Padding(6, 1, 6, 1) + .CornerRadius(4) + .VAlign(VerticalAlignment.Center) + .Background(Theme.AccentSecondary); } - private static void ApplyQueryHighlight(TextBlock textBlock, string? query) + private static void ApplyQueryHighlight(TextBlock textBlock, string? query, ColorScheme colorScheme) { textBlock.TextHighlighters.Clear(); var text = textBlock.Text ?? string.Empty; var normalized = (query ?? string.Empty).Trim().TrimStart('/').Trim(); - if (normalized.Length == 0 || text.Length < normalized.Length) + if (normalized.Length == 0 || text.Length < normalized.Length || colorScheme == ColorScheme.HighContrast) + return; + + var isDark = colorScheme == ColorScheme.Dark; + if (ThemeRef.Resolve("AccentFillColorDefaultBrush", isDark) is not SolidColorBrush accent + || ThemeRef.Resolve("TextFillColorPrimaryBrush", isDark) is not Brush foreground) return; - var accent = Application.Current.Resources["AccentFillColorDefaultBrush"] as SolidColorBrush - ?? new SolidColorBrush(Microsoft.UI.Colors.SteelBlue); var accentColor = accent.Color; var highlighter = new Microsoft.UI.Xaml.Documents.TextHighlighter { Background = new SolidColorBrush(global::Windows.UI.Color.FromArgb(31, accentColor.R, accentColor.G, accentColor.B)), - Foreground = Application.Current.Resources["TextFillColorPrimaryBrush"] as Brush - ?? new SolidColorBrush(Microsoft.UI.Colors.White), + Foreground = foreground, }; for (var index = 0; index <= text.Length - normalized.Length;) diff --git a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml index e3d118ca7..4b6220e84 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/ConnectionPage.xaml @@ -19,6 +19,42 @@ + + + + + + + + + + + + @@ -390,12 +392,6 @@ as the WinUI default once rows are bigger. --> 0,2,0,2 - diff --git a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs index 550f847a6..0219a8696 100644 --- a/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Windows/HubWindow.xaml.cs @@ -1396,12 +1396,9 @@ private void ExecuteCommand(CommandItem cmd) #region High Contrast icon fallback - // Maps NavigationViewItem.Tag -> Segoe Fluent Icons glyph used as fallback - // when Windows High Contrast is active. FontIcon uses the system foreground - // brush so it auto-adapts to every HC variant (HC Black/White/#1/#2); our - // multi-color SVGs don't, so we swap them out at construction. This mirrors - // the original gray Segoe Fluent Icons that were here before the colorful - // refresh — same glyphs as those Windows users learned in earlier builds. + // Fixed-color SVGs provide the normal Hub presentation. In High Contrast, + // App.xaml resolves this path to system-foreground Fluent glyphs without + // constructing or subscribing to the legacy WinRT accessibility API. private static readonly Dictionary s_highContrastGlyphFallback = new() { { "chat", "\uE8BD" }, @@ -1424,36 +1421,29 @@ private void ExecuteCommand(CommandItem cmd) { "debug", "\uEBE8" }, }; - // Glyphs for the two parent NavigationViewItems that don't carry a Tag - // ("Advanced" group and "Agents" group). These also feed the dynamic agent - // items added at runtime. private const string AdvancedGroupGlyph = "\uE950"; private const string AgentsGroupGlyph = "\uE99A"; - private bool _isHighContrast; private void ApplyHighContrastFallbackIfNeeded() { - try - { - var settings = new global::Windows.UI.ViewManagement.AccessibilitySettings(); - _isHighContrast = settings.HighContrast; - } - catch - { - _isHighContrast = false; + const string resourceKey = "HubNavigationUseHighContrastIcons"; + _isHighContrast = Application.Current.Resources.ContainsKey(resourceKey) + && Application.Current.Resources[resourceKey] is true; + if (!_isHighContrast) return; - } - if (!_isHighContrast) return; + SwapToFontIcons(NavView.MenuItems); SwapToFontIcons(NavView.FooterMenuItems); } private void SwapToFontIcons(IList items) { - foreach (var obj in items) + foreach (var value in items) { - if (obj is not NavigationViewItem item) continue; + if (value is not NavigationViewItem item) + continue; + item.Icon = ResolveHighContrastIcon(item); if (item.MenuItems.Count > 0) SwapToFontIcons(item.MenuItems); @@ -1465,26 +1455,27 @@ private IconElement ResolveHighContrastIcon(NavigationViewItem item) if (item.Tag is string tag) { if (s_highContrastGlyphFallback.TryGetValue(tag, out var glyph)) - return new FontIcon { Glyph = glyph }; + return FluentIconCatalog.Build(glyph, 20); if (tag.StartsWith("agent:", StringComparison.Ordinal)) - return new FontIcon { Glyph = AgentsGroupGlyph }; + return FluentIconCatalog.Build(AgentsGroupGlyph, 20); } + if (item == AgentsNavItem) - return new FontIcon { Glyph = AgentsGroupGlyph }; - if (item.Content is string content && content.Equals("Advanced", StringComparison.OrdinalIgnoreCase)) - return new FontIcon { Glyph = AdvancedGroupGlyph }; - // Fall back to whatever the XAML provided (keeps the colorful icon - // rather than blanking it out for unmapped items). - return item.Icon ?? new FontIcon { Glyph = "\uE700" }; + return FluentIconCatalog.Build(AgentsGroupGlyph, 20); + if (item == NavAdvanced) + return FluentIconCatalog.Build(AdvancedGroupGlyph, 20); + + return FluentIconCatalog.Build("\uE700", 20); } private IconElement BuildAgentItemIcon() { if (_isHighContrast) - return new FontIcon { Glyph = AgentsGroupGlyph }; + return FluentIconCatalog.Build(AgentsGroupGlyph, 20); + return new ImageIcon { - Source = (Microsoft.UI.Xaml.Media.ImageSource)NavView.Resources["Agents_Icon"] + Source = (ImageSource)NavView.Resources["Agents_Icon"] }; } diff --git a/tests/OpenClaw.Tray.Tests/AccessibilityThemeResourceSourceTests.cs b/tests/OpenClaw.Tray.Tests/AccessibilityThemeResourceSourceTests.cs new file mode 100644 index 000000000..65b6b1c54 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/AccessibilityThemeResourceSourceTests.cs @@ -0,0 +1,58 @@ +namespace OpenClaw.Tray.Tests; + +public sealed class AccessibilityThemeResourceSourceTests +{ + [Fact] + public void TrayThemeChanges_AreOwnedByXamlResourcesInsteadOfAccessibilitySettings() + { + var connection = ReadSource("src", "OpenClaw.Tray.WinUI", "Pages", "ConnectionPage.xaml.cs"); + var connectionXaml = ReadSource("src", "OpenClaw.Tray.WinUI", "Pages", "ConnectionPage.xaml"); + var hub = ReadSource("src", "OpenClaw.Tray.WinUI", "Windows", "HubWindow.xaml.cs"); + var hubXaml = ReadSource("src", "OpenClaw.Tray.WinUI", "Windows", "HubWindow.xaml"); + var timeline = ReadSource("src", "OpenClaw.Tray.WinUI", "Chat", "OpenClawChatTimeline.cs"); + var resources = ReadSource("src", "OpenClaw.Tray.WinUI", "App.xaml"); + + Assert.DoesNotContain("AccessibilitySettings", connection); + Assert.DoesNotContain("HighContrastChanged", connection); + Assert.DoesNotContain("TrySubscribeAccessibilitySettings", connection); + Assert.DoesNotContain("AccessibilitySettings", hub); + Assert.DoesNotContain("HighContrastChanged", hub); + Assert.DoesNotContain("AccessibilitySettings", timeline); + Assert.DoesNotContain("TryDetectHighContrast", timeline); + + Assert.Contains("ConnectionCapabilityPillActiveBorderStyle", connection); + Assert.Contains("ChatUserBubbleSelectionStyle", timeline); + Assert.Contains("ChatToolCardBorderStyle", timeline); + Assert.Contains("ConnectionCapabilityPillSuccessBrush", connectionXaml); + Assert.Contains("ImageIcon Source=\"{StaticResource Chat_Icon}\"", hubXaml); + Assert.Contains("new ImageIcon", hub); + Assert.Contains("NavView.Resources[\"Agents_Icon\"]", hub); + Assert.Contains("ApplyHighContrastFallbackIfNeeded", hub); + Assert.Contains("HubNavigationUseHighContrastIcons", hub); + Assert.Contains("SwapToFontIcons", hub); + Assert.Contains("FluentIconCatalog.Build", hub); + Assert.Contains("item == NavAdvanced", hub); + Assert.DoesNotContain("content.Equals(\"Advanced\"", hub); + Assert.Contains("return FluentIconCatalog.Build(\"\\uE700\", 20);", hub); + Assert.DoesNotContain("", resources); + Assert.Contains("SystemColorWindowColor", resources); + Assert.Contains("ChatUserBubbleSelectionHighlightBrush", resources); + Assert.Contains("SystemColorHighlightColor", resources); + Assert.Contains("2", resources); + Assert.Contains("True", resources); + } + + private static string ReadSource(params string[] relativePathParts) + { + var root = TestRepositoryPaths.GetRepositoryRoot(); + return File.ReadAllText(Path.Combine(new[] { root }.Concat(relativePathParts).ToArray())); + } +} diff --git a/tests/OpenClaw.Tray.Tests/ChatTimelinePresentationTests.cs b/tests/OpenClaw.Tray.Tests/ChatTimelinePresentationTests.cs index e39054167..0587b0621 100644 --- a/tests/OpenClaw.Tray.Tests/ChatTimelinePresentationTests.cs +++ b/tests/OpenClaw.Tray.Tests/ChatTimelinePresentationTests.cs @@ -131,6 +131,58 @@ public void ReactorComposer_BoundsAndAnnouncesQueuedMessages() Assert.Contains("AutomationLiveSetting.Polite", root); } + [Fact] + public void ReactorComposer_UsesReactorThemeResourcesWithoutManualThemeObservation() + { + var root = File.ReadAllText(Path.Combine( + TestRepositoryPaths.GetRepositoryRoot(), + "src", + "OpenClaw.Tray.WinUI", + "Chat", + "OpenClawReactorChatRoot.cs")); + var composer = root[root.IndexOf( + "public sealed class ReactorChatComposer", + StringComparison.Ordinal)..]; + + Assert.Contains("UseColorScheme()", composer); + Assert.Contains(".Background(Theme.ControlFill)", composer); + Assert.Contains(".BorderBrush(Theme.ControlStroke)", composer); + Assert.Contains("Theme.Ref(\"AcrylicBackgroundFillColorDefaultBrush\")", composer); + Assert.Contains("Theme.Ref(\"SurfaceStrokeColorFlyoutBrush\")", composer); + Assert.Contains("Theme.Ref(\"SubtleFillColorTertiaryBrush\")", composer); + Assert.Contains("colorScheme);", composer); + Assert.Contains("CreateSlashPopupHost(BuildSlashPopup(", composer); + + Assert.DoesNotContain("AccessibilitySettings", composer); + Assert.DoesNotContain("HighContrastChanged", composer); + Assert.DoesNotContain("ConditionalWeakTable", composer); + Assert.DoesNotContain("ApplyTheme(", composer); + Assert.DoesNotContain("ResolveThemeBrush", composer); + Assert.DoesNotContain("FindThemedResource", composer); + Assert.DoesNotContain("SearchThemeDictionaries", composer); + Assert.DoesNotContain("LookupResource", composer); + Assert.DoesNotContain("Application.Current.Resources", composer); + } + + [Fact] + public void ReactorComposer_LocalizesSettingsTooltipInEveryLocale() + { + var stringsDirectory = Path.Combine( + TestRepositoryPaths.GetRepositoryRoot(), + "src", + "OpenClaw.Tray.WinUI", + "Strings"); + + foreach (var resourceFile in Directory.EnumerateFiles( + stringsDirectory, + "Resources.resw", + SearchOption.AllDirectories)) + { + var resources = File.ReadAllText(resourceFile); + Assert.Contains("Chat_Composer_Tooltip_Settings", resources); + } + } + [Fact] public void ReactorRoot_SettlesWelcomeEligibilityBeforeShowingEmptyState() { diff --git a/tests/OpenClaw.Tray.UITests/ChatTimelineThemeResourceProofTests.cs b/tests/OpenClaw.Tray.UITests/ChatTimelineThemeResourceProofTests.cs new file mode 100644 index 000000000..99f0a2587 --- /dev/null +++ b/tests/OpenClaw.Tray.UITests/ChatTimelineThemeResourceProofTests.cs @@ -0,0 +1,117 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using OpenClaw.Chat; +using OpenClawTray.Chat; +using OpenClawTray.FunctionalUI.Hosting; +using static OpenClawTray.FunctionalUI.Factories; +using static OpenClaw.Tray.UITests.TestSupport; + +namespace OpenClaw.Tray.UITests; + +[Collection(UICollection.Name)] +public sealed class ChatTimelineThemeResourceProofTests +{ + private readonly UIThreadFixture _ui; + + public ChatTimelineThemeResourceProofTests(UIThreadFixture ui) => _ui = ui; + + [Fact] + public async Task ToolBurst_RendersWithTestHostBorderStyle() + { + var props = BuildProps( + [ + new ChatTimelineItem("user-1", ChatTimelineItemKind.User, "Check status"), + new ChatTimelineItem("assistant-1", ChatTimelineItemKind.Assistant, "Checking."), + new ChatTimelineItem( + "tool-1", + ChatTimelineItemKind.ToolCall, + "{}", + ToolName: "session_status", + ToolResult: ChatToolCallStatus.Success, + ToolOutput: "ok"), + ]); + + var host = await MountAsync(props); + + await _ui.RunOnUIAsync(() => + { + var style = Assert.IsType"); - EnsureFluentBrushFallbacks(resources); } @@ -86,6 +86,8 @@ internal static void EnsureFluentBrushFallbacks(ResourceDictionary resources) { TryAddBrushResource(resources, key, color); } + + AddChatTimelineStyles(resources); } private bool TryGetResources(out ResourceDictionary resources) @@ -133,4 +135,39 @@ private static void TryAddBrushResource(ResourceDictionary resources, string key // best-effort; missing key just means renderers fall back. } } + + private static void AddChatTimelineStyles(ResourceDictionary resources) + { + if (!resources.ContainsKey("ChatUserBubbleSelectionStyle")) + { + resources["ChatUserBubbleSelectionStyle"] = new Style + { + TargetType = typeof(RichTextBlock), + }; + } + + AddChatBorderStyle(resources, "ChatToolCardBorderStyle"); + AddChatBorderStyle(resources, "ChatCompactionCardStyle"); + } + + private static void AddChatBorderStyle(ResourceDictionary resources, string key) + { + if (resources.ContainsKey(key)) + return; + + var style = new Style + { + TargetType = typeof(Border), + }; + style.Setters.Add(new Setter( + Border.BackgroundProperty, + resources["CardBackgroundFillColorDefaultBrush"])); + style.Setters.Add(new Setter( + Border.BorderBrushProperty, + resources["ControlStrokeColorDefaultBrush"])); + style.Setters.Add(new Setter( + Border.BorderThicknessProperty, + new Thickness(1))); + resources[key] = style; + } }