Skip to content

Commit bc962af

Browse files
Implement system titlebar theme integration for Windows: introduce IWindowFrameTheme and WindowsFrameTheme to synchronize the titlebar's light/dark mode with the app's theme. Update MainWindow, App, and related resources to remove custom titlebar styles and fully leverage the system-provided frame.
1 parent 535d9f6 commit bc962af

11 files changed

Lines changed: 81 additions & 107 deletions

‎src/AI.Desktop/App.axaml‎

Lines changed: 5 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -4,91 +4,45 @@
44
x:Class="AI.Desktop.App"
55
RequestedThemeVariant="Dark">
66
<!--
7-
The drawn titlebar is not a child of the window, so its brushes resolve here, not in
8-
Window.Resources. Caption* and TitleBarBackgroundBrush are the names Fluent's
9-
WindowDrawnDecorations theme looks up; the rest are the window's own. The values follow
10-
the page's tokens (app.css): App.ApplyTheme switches between the sets when the page
11-
changes its theme.
7+
The window's own brushes, for what shows before the page loads or when it cannot. The values
8+
follow the page's tokens (app.css): App.ApplyTheme switches between the sets when the page
9+
changes its theme, and the system titlebar follows the set's light or dark base.
1210
-->
1311
<Application.Resources>
1412
<ResourceDictionary>
1513
<ResourceDictionary.ThemeDictionaries>
1614
<ResourceDictionary x:Key="Dark">
17-
<SolidColorBrush x:Key="TitleBarBackgroundBrush" Color="#202020" />
18-
<SolidColorBrush x:Key="TitleBarBorderBrush" Color="#2E2E2E" />
19-
<SolidColorBrush x:Key="CaptionButtonForeground" Color="#ECECEC" />
20-
<SolidColorBrush x:Key="CaptionButtonBackground" Color="#2B2B2B" />
21-
<SolidColorBrush x:Key="CaptionButtonBorderBrush" Color="#454545" />
2215
<SolidColorBrush x:Key="WindowBackgroundBrush" Color="#171717" />
23-
<SolidColorBrush x:Key="WindowFrameBrush" Color="#3A3A3A" />
2416
<SolidColorBrush x:Key="StatusTitleBrush" Color="#E5E5E5" />
2517
<SolidColorBrush x:Key="StatusDetailBrush" Color="#A3A3A3" />
2618
</ResourceDictionary>
2719
<!-- The page's dark blue palette (app.css), for the chrome it cannot paint. -->
2820
<ResourceDictionary x:Key="{x:Static desktop:DesktopThemes.DarkBlue}">
29-
<SolidColorBrush x:Key="TitleBarBackgroundBrush" Color="#17212D" />
30-
<SolidColorBrush x:Key="TitleBarBorderBrush" Color="#24313F" />
31-
<SolidColorBrush x:Key="CaptionButtonForeground" Color="#E8EDF4" />
32-
<SolidColorBrush x:Key="CaptionButtonBackground" Color="#212C39" />
33-
<SolidColorBrush x:Key="CaptionButtonBorderBrush" Color="#3A4A5C" />
3421
<SolidColorBrush x:Key="WindowBackgroundBrush" Color="#101720" />
35-
<SolidColorBrush x:Key="WindowFrameBrush" Color="#2D3A4A" />
3622
<SolidColorBrush x:Key="StatusTitleBrush" Color="#E8EDF4" />
3723
<SolidColorBrush x:Key="StatusDetailBrush" Color="#A3B0C0" />
3824
</ResourceDictionary>
39-
<!-- The page's neutral grey palette (app.css), for the chrome it cannot paint. The
40-
colours mirror the CSS tokens one-for-one so the drawn titlebar reads as the
41-
same dark grey as the rest of the workspace. -->
25+
<!-- The page's neutral grey palette (app.css), for the chrome it cannot paint. -->
4226
<ResourceDictionary x:Key="{x:Static desktop:DesktopThemes.Gray}">
43-
<SolidColorBrush x:Key="TitleBarBackgroundBrush" Color="#262626" />
44-
<SolidColorBrush x:Key="TitleBarBorderBrush" Color="#2E2E2E" />
45-
<SolidColorBrush x:Key="CaptionButtonForeground" Color="#ECECEC" />
46-
<SolidColorBrush x:Key="CaptionButtonBackground" Color="#2E2E2E" />
47-
<SolidColorBrush x:Key="CaptionButtonBorderBrush" Color="#3C3C3C" />
4827
<SolidColorBrush x:Key="WindowBackgroundBrush" Color="#212121" />
49-
<SolidColorBrush x:Key="WindowFrameBrush" Color="#3C3C3C" />
5028
<SolidColorBrush x:Key="StatusTitleBrush" Color="#ECECEC" />
5129
<SolidColorBrush x:Key="StatusDetailBrush" Color="#AAAAAA" />
5230
</ResourceDictionary>
53-
<!-- The page's light grey palette (app.css), for the chrome it cannot paint. The
54-
colours mirror the CSS tokens one-for-one so the drawn titlebar reads as the
55-
same soft grey as the rest of the workspace. -->
31+
<!-- The page's light grey palette (app.css), for the chrome it cannot paint. -->
5632
<ResourceDictionary x:Key="{x:Static desktop:DesktopThemes.LightGray}">
57-
<SolidColorBrush x:Key="TitleBarBackgroundBrush" Color="#F5F5F5" />
58-
<SolidColorBrush x:Key="TitleBarBorderBrush" Color="#D6D6D6" />
59-
<SolidColorBrush x:Key="CaptionButtonForeground" Color="#1F1F1F" />
60-
<SolidColorBrush x:Key="CaptionButtonBackground" Color="#E2E2E2" />
61-
<SolidColorBrush x:Key="CaptionButtonBorderBrush" Color="#C5C5C5" />
6233
<SolidColorBrush x:Key="WindowBackgroundBrush" Color="#F2F2F2" />
63-
<SolidColorBrush x:Key="WindowFrameBrush" Color="#C5C5C5" />
6434
<SolidColorBrush x:Key="StatusTitleBrush" Color="#1F1F1F" />
6535
<SolidColorBrush x:Key="StatusDetailBrush" Color="#5C5C5C" />
6636
</ResourceDictionary>
6737
<ResourceDictionary x:Key="Light">
68-
<SolidColorBrush x:Key="TitleBarBackgroundBrush" Color="#F3F3F3" />
69-
<SolidColorBrush x:Key="TitleBarBorderBrush" Color="#E0E0E0" />
70-
<SolidColorBrush x:Key="CaptionButtonForeground" Color="#1F1F1F" />
71-
<SolidColorBrush x:Key="CaptionButtonBackground" Color="#E2E2E2" />
72-
<SolidColorBrush x:Key="CaptionButtonBorderBrush" Color="#CFCFCF" />
7338
<SolidColorBrush x:Key="WindowBackgroundBrush" Color="#FFFFFF" />
74-
<SolidColorBrush x:Key="WindowFrameBrush" Color="#CFCFCF" />
7539
<SolidColorBrush x:Key="StatusTitleBrush" Color="#1F1F1F" />
7640
<SolidColorBrush x:Key="StatusDetailBrush" Color="#575757" />
7741
</ResourceDictionary>
7842
</ResourceDictionary.ThemeDictionaries>
79-
<x:Double x:Key="CaptionButtonWidth">46</x:Double>
80-
<x:Double x:Key="CaptionButtonHeight">38</x:Double>
8143
</ResourceDictionary>
8244
</Application.Resources>
8345
<Application.Styles>
8446
<FluentTheme />
85-
<!--
86-
No full-screen button: leaving full screen on Windows restores the window through
87-
SW_RESTORE, which unmaximizes it and moves a snapped one back to its pre-snap place,
88-
and a maximized window already gives this app the whole screen.
89-
-->
90-
<Style Selector="WindowDrawnDecorations /template/ Button#PART_FullScreenButton">
91-
<Setter Property="IsVisible" Value="False" />
92-
</Style>
9347
</Application.Styles>
9448
</Application>

‎src/AI.Desktop/App.axaml.cs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ namespace AI.Desktop;
88

99
/// <remarks>
1010
/// The window comes as a factory: a window built before <see cref="Initialize"/> loads the theme
11-
/// never gets its drawn titlebar template, so the titlebar neither drags nor clicks.
11+
/// would miss its styles and brushes.
1212
/// </remarks>
1313
internal sealed partial class App(Func<MainWindow> mainWindow, IProcessSignals processSignals, IThemePreferenceStore themes)
1414
: Application
@@ -40,8 +40,8 @@ public override void OnFrameworkInitializationCompleted()
4040
}
4141

4242
/// <summary>
43-
/// The titlebar's brushes live in this application's theme dictionaries, so the variant is set
44-
/// here rather than on the window. "system" leaves it to the OS, which is what the page does too;
43+
/// The window's brushes live in this application's theme dictionaries, and the system titlebar
44+
/// takes its light or dark look from the variant, so it is set here rather than on the window. "system" leaves it to the OS, which is what the page does too;
4545
/// an unknown preference keeps following the OS rather than guessing.
4646
/// </summary>
4747
private void ApplyTheme(string preference) =>

‎src/AI.Desktop/Composition.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,5 +50,5 @@ private static void Setup() =>
5050
.Singleton<App>()
5151
.Transient<UpdateManagerFactory, GitHubUpdateFeed, UpdateInstaller, UpdateInstallationProvider,
5252
MainWindow, ProcessSignals, DesktopUpdates, JsonWindowPlacementStore, JsonWorkspaceLocationStore,
53-
JsonThemePreferenceStore, JsonClientSettingsStore, WindowsTaskbarBadge, WebView2FileDropBridge>();
53+
JsonThemePreferenceStore, JsonClientSettingsStore, WindowsTaskbarBadge, WindowsFrameTheme, WebView2FileDropBridge>();
5454
}

‎src/AI.Desktop/DesktopThemes.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ namespace AI.Desktop;
33
using Avalonia.Styling;
44

55
/// <summary>
6-
/// The theme variants this window can paint its own chrome and drawn titlebar in. Light and Dark
6+
/// The theme variants this window can paint its own chrome in. Light and Dark
77
/// are Avalonia's; Dark blue is the app's own, a <see cref="ThemeVariant"/> derived from
88
/// <see cref="ThemeVariant.Dark"/> so every key the built-in Fluent theme does not define for it
99
/// still resolves to the dark value.

‎src/AI.Desktop/IThemePreferenceStore.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
namespace AI.Desktop;
22

33
/// <summary>
4-
/// Keeps the theme the page last asked for, so the next run paints the titlebar in it before the
4+
/// Keeps the theme the page last asked for, so the next run paints the window in it before the
55
/// page has loaded and said so again.
66
/// </summary>
77
internal interface IThemePreferenceStore
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace AI.Desktop;
2+
3+
/// <summary>Keeps the system titlebar and frame light or dark along with the window's theme.</summary>
4+
internal interface IWindowFrameTheme
5+
{
6+
void Apply(IntPtr windowHandle, bool dark);
7+
}

‎src/AI.Desktop/JsonThemePreferenceStore.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ public void Save(string preference)
2929
}
3030
catch (Exception error) when (error is IOException or UnauthorizedAccessException)
3131
{
32-
// The titlebar then starts in the system theme next time and catches up once the page loads.
32+
// The window then starts in the system theme next time and catches up once the page loads.
3333
}
3434
}
3535

‎src/AI.Desktop/MainWindow.axaml‎

Lines changed: 14 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -7,36 +7,19 @@
77
MinWidth="360" MinHeight="500"
88
WindowStartupLocation="CenterScreen"
99
WindowDecorations="Full"
10-
ExtendClientAreaToDecorationsHint="True"
11-
ExtendClientAreaTitleBarHeightHint="38"
1210
Background="{DynamicResource WindowBackgroundBrush}">
13-
<Panel>
14-
<Grid x:Name="Chrome" RowDefinitions="38,*" Margin="1">
15-
<!--
16-
Avalonia handles the titlebar in this reserved row; the window background covers
17-
its own titlebar fill, so the fill is drawn here and lets the pointer through.
18-
-->
19-
<Border Grid.Row="0" Background="{DynamicResource TitleBarBackgroundBrush}" BorderBrush="{DynamicResource TitleBarBorderBrush}" BorderThickness="0,0,0,1"
20-
IsHitTestVisible="False" />
21-
<Grid Grid.Row="1">
22-
<!-- A native WebView must stay below the titlebar; its HWND would cover Avalonia controls. -->
23-
<NativeWebView x:Name="WebView" Background="{DynamicResource WindowBackgroundBrush}" IsVisible="False" />
24-
<Border x:Name="Status" Background="{DynamicResource WindowBackgroundBrush}" IsVisible="True">
25-
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="12" MaxWidth="560">
26-
<TextBlock x:Name="StatusTitle" Text="Starting AI Client…" FontSize="18" Foreground="{DynamicResource StatusTitleBrush}"
27-
HorizontalAlignment="Center" />
28-
<TextBlock x:Name="StatusDetail" Foreground="{DynamicResource StatusDetailBrush}" TextWrapping="Wrap" TextAlignment="Center"
29-
IsVisible="False" />
30-
<Button x:Name="Retry" Content="Retry" HorizontalAlignment="Center" IsVisible="False" />
31-
<Button x:Name="UpdateHost" Content="Update Host" HorizontalAlignment="Center" IsVisible="False" />
32-
</StackPanel>
33-
</Border>
34-
</Grid>
35-
</Grid>
36-
<!--
37-
Windows 10 draws no visible edge around a dark window, so the outline is ours. It sits
38-
beside the content, not around it: the WebView HWND stays 1px inside and cannot cover it.
39-
-->
40-
<Border x:Name="Frame" BorderBrush="{DynamicResource WindowFrameBrush}" BorderThickness="1" IsHitTestVisible="False" />
41-
</Panel>
11+
<!-- The titlebar and frame are the system's own, so they look the way the user knows from every other app. -->
12+
<Grid>
13+
<NativeWebView x:Name="WebView" Background="{DynamicResource WindowBackgroundBrush}" IsVisible="False" />
14+
<Border x:Name="Status" Background="{DynamicResource WindowBackgroundBrush}" IsVisible="True">
15+
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="12" MaxWidth="560">
16+
<TextBlock x:Name="StatusTitle" Text="Starting AI Client…" FontSize="18" Foreground="{DynamicResource StatusTitleBrush}"
17+
HorizontalAlignment="Center" />
18+
<TextBlock x:Name="StatusDetail" Foreground="{DynamicResource StatusDetailBrush}" TextWrapping="Wrap" TextAlignment="Center"
19+
IsVisible="False" />
20+
<Button x:Name="Retry" Content="Retry" HorizontalAlignment="Center" IsVisible="False" />
21+
<Button x:Name="UpdateHost" Content="Update Host" HorizontalAlignment="Center" IsVisible="False" />
22+
</StackPanel>
23+
</Border>
24+
</Grid>
4225
</Window>

‎src/AI.Desktop/MainWindow.axaml.cs‎

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ namespace AI.Desktop;
44
using Avalonia.Controls;
55
using Avalonia.Interactivity;
66
using Avalonia.Platform;
7+
using Avalonia.Styling;
78
using Avalonia.Threading;
89
using System.Text.Json;
910

@@ -19,6 +20,7 @@ internal sealed partial class MainWindow : Window
1920
private readonly IClientSettingsStore _clientSettings;
2021
private readonly ITaskbarBadge _taskbarBadge;
2122
private readonly IFileDropBridge _fileDrop;
23+
private readonly IWindowFrameTheme _frameTheme;
2224
private readonly IDesktopUpdates _updates;
2325
private readonly DispatcherTimer _engineWatchdog;
2426
private bool _engineCreated;
@@ -30,14 +32,15 @@ internal sealed partial class MainWindow : Window
3032

3133
public MainWindow(DesktopStart start, IWindowPlacementStore placements,
3234
IWorkspaceLocationStore workspaceLocation, IClientSettingsStore clientSettings,
33-
ITaskbarBadge taskbarBadge, IFileDropBridge fileDrop, IDesktopUpdates updates)
35+
ITaskbarBadge taskbarBadge, IFileDropBridge fileDrop, IWindowFrameTheme frameTheme, IDesktopUpdates updates)
3436
{
3537
_start = start;
3638
_placements = placements;
3739
_workspaceLocation = workspaceLocation;
3840
_clientSettings = clientSettings;
3941
_taskbarBadge = taskbarBadge;
4042
_fileDrop = fileDrop;
43+
_frameTheme = frameTheme;
4144
_updates = updates;
4245
_updates.ShutdownRequested += () => Dispatcher.UIThread.Post(Close);
4346
InitializeComponent();
@@ -58,20 +61,13 @@ public MainWindow(DesktopStart start, IWindowPlacementStore placements,
5861
Retry.Click += (_, _) => Load();
5962
UpdateHost.IsVisible = start.HostUpdateAddress is not null;
6063
UpdateHost.Click += async (_, _) => await UpdateIncompatibleHostAsync();
61-
PropertyChanged += (_, args) =>
62-
{
63-
if (args.Property == WindowStateProperty)
64-
{
65-
UpdateFrame();
66-
}
67-
};
68-
UpdateFrame();
6964
}
7065

7166
protected override void OnOpened(EventArgs e)
7267
{
7368
base.OnOpened(e);
7469
if (OperatingSystem.IsWindows()) _taskbarBadge.Attach(TryGetPlatformHandle()?.Handle ?? IntPtr.Zero);
70+
ApplyFrameTheme();
7571
if (_start.Address is null)
7672
{
7773
ShowProblem("The AI Client server did not start.", _start.Error, canRetry: false);
@@ -89,6 +85,18 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang
8985
{
9086
RememberNormalLater();
9187
}
88+
else if (change.Property == ActualThemeVariantProperty && IsVisible)
89+
{
90+
ApplyFrameTheme();
91+
}
92+
}
93+
94+
/// <summary>The app's own variants derive from Light or Dark; the system titlebar knows only those two.</summary>
95+
private void ApplyFrameTheme()
96+
{
97+
var variant = ActualThemeVariant;
98+
while (variant.InheritVariant is { } parent) variant = parent;
99+
_frameTheme.Apply(TryGetPlatformHandle()?.Handle ?? IntPtr.Zero, variant == ThemeVariant.Dark);
92100
}
93101

94102
protected override void OnClosing(WindowClosingEventArgs e)
@@ -398,12 +406,4 @@ private static string EngineHint() =>
398406
: OperatingSystem.IsLinux()
399407
? "Install WebKitGTK (for example libwebkit2gtk-4.1-0) or WPE WebKit and start AI Client again."
400408
: "The system web view did not start. Restart AI Client; if it happens again, report it.";
401-
402-
/// <summary>A maximized or full-screen window has no edge to outline.</summary>
403-
private void UpdateFrame()
404-
{
405-
var edge = WindowState is WindowState.Maximized or WindowState.FullScreen ? 0 : 1;
406-
Frame.BorderThickness = new Thickness(edge);
407-
Chrome.Margin = new Thickness(edge);
408-
}
409409
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
namespace AI.Desktop;
2+
3+
using System.Runtime.InteropServices;
4+
5+
/// <summary>
6+
/// Avalonia darkens the system titlebar only on Windows 11, so Windows 10 kept a white titlebar
7+
/// over a dark workspace. The attribute has been honoured since Windows 10 2004 (build 19041);
8+
/// older builds and other systems ignore the call.
9+
/// </summary>
10+
internal sealed partial class WindowsFrameTheme : IWindowFrameTheme
11+
{
12+
private const int UseImmersiveDarkMode = 20;
13+
private const uint NoSize = 0x1, NoMove = 0x2, NoZOrder = 0x4, NoActivate = 0x10, FrameChanged = 0x20;
14+
15+
public void Apply(IntPtr windowHandle, bool dark)
16+
{
17+
if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 19041) || windowHandle == IntPtr.Zero) return;
18+
var value = dark ? 1 : 0;
19+
if (DwmSetWindowAttribute(windowHandle, UseImmersiveDarkMode, ref value, sizeof(int)) < 0) return;
20+
// Windows 10 repaints the non-client area only when told the frame changed.
21+
SetWindowPos(windowHandle, IntPtr.Zero, 0, 0, 0, 0, NoSize | NoMove | NoZOrder | NoActivate | FrameChanged);
22+
}
23+
24+
[LibraryImport("dwmapi.dll")]
25+
private static partial int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int value, int size);
26+
27+
[LibraryImport("user32.dll")]
28+
[return: MarshalAs(UnmanagedType.Bool)]
29+
private static partial bool SetWindowPos(IntPtr hwnd, IntPtr insertAfter, int x, int y, int width, int height, uint flags);
30+
}

0 commit comments

Comments
 (0)