Skip to content

Commit 2de7aae

Browse files
authored
Fix native Windows maximize for the Avalonia main window (#4749)
1 parent f505192 commit 2de7aae

2 files changed

Lines changed: 139 additions & 134 deletions

File tree

src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,17 +70,17 @@
7070
</Target>
7171

7272
<ItemGroup>
73-
<PackageReference Include="Avalonia" Version="12.0.0-rc1" />
74-
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.0.0-rc1" />
75-
<PackageReference Include="Avalonia.Desktop" Version="12.0.0-rc1" />
76-
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.0-rc1" />
73+
<PackageReference Include="Avalonia" Version="12.0.3" />
74+
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.0.0" />
75+
<PackageReference Include="Avalonia.Desktop" Version="12.0.3" />
76+
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.3" />
7777
<PackageReference Include="Devolutions.AvaloniaTheme.DevExpress" Version="2026.3.13" />
7878
<PackageReference Include="Devolutions.AvaloniaTheme.MacOS" Version="2026.3.13" />
7979
<PackageReference Include="Devolutions.AvaloniaTheme.Linux" Version="2026.3.11" />
8080
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.0-beta3" Condition="'$(EnableAvaloniaDiagnostics)' == 'true'" />
8181
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.*" />
8282
<PackageReference Include="Octokit" Version="14.0.0" />
83-
<PackageReference Include="Avalonia.Controls.WebView" Version="12.0.0-rc1" />
83+
<PackageReference Include="Avalonia.Controls.WebView" Version="12.0.0" />
8484
<PackageReference Include="Tmds.DBus.Protocol" Version="0.92.0" />
8585
</ItemGroup>
8686

src/UniGetUI.Avalonia/Views/MainWindow.axaml.cs

Lines changed: 134 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using Avalonia;
44
using Avalonia.Controls;
55
using Avalonia.Controls.ApplicationLifetimes;
6+
using Avalonia.Data;
67
using Avalonia.Input;
78
using Avalonia.Interactivity;
89
using Avalonia.Layout;
@@ -39,19 +40,24 @@ public enum PageType
3940

4041
public partial class MainWindow : Window
4142
{
42-
private const int SW_RESTORE = 9;
43+
// Workaround for Avalonia 12 issue #21160 / #21212: BorderOnly + ExtendClientArea
44+
// strips WS_CAPTION / WS_THICKFRAME, which makes DWM disable Aero Snap drag-to-top,
45+
// Win+Up, and the maximize/minimize/restore animations. Re-add those bits on every
46+
// style change. WM_GETMINMAXINFO is also overridden because Avalonia's default values
47+
// on the primary monitor make Aero Snap maximize to the current window size (no-op).
48+
// Targeted upstream fix in Avalonia 12.1.
49+
private const uint WM_STYLECHANGING = 0x007C;
50+
private const uint WM_GETMINMAXINFO = 0x0024;
51+
private const int GWL_STYLE = -16;
52+
private const uint WS_CAPTION = 0x00C00000;
53+
private const uint WS_THICKFRAME = 0x00040000;
54+
private const uint WS_MINIMIZEBOX = 0x00020000;
55+
private const uint WS_MAXIMIZEBOX = 0x00010000;
4356
private const uint MONITOR_DEFAULTTONEAREST = 2;
44-
private const int SM_CXSIZEFRAME = 32;
45-
private const int SM_CYSIZEFRAME = 33;
46-
private const int SM_CXPADDEDBORDER = 92;
47-
private const uint SWP_NOZORDER = 0x0004;
48-
private const uint SWP_NOACTIVATE = 0x0010;
49-
private const uint SWP_FRAMECHANGED = 0x0020;
5057

5158
private bool _focusSidebarSelectionOnNextPageChange;
5259
private TrayService? _trayService;
5360
private bool _allowClose;
54-
private NativeMethods.RECT? _windowsRestoreBoundsBeforeManualMaximize;
5561

5662
public enum RuntimeNotificationLevel
5763
{
@@ -79,6 +85,27 @@ public MainWindow()
7985
_trayService.UpdateStatus();
8086
}
8187

88+
protected override void OnOpened(EventArgs e)
89+
{
90+
base.OnOpened(e);
91+
if (!OperatingSystem.IsWindows())
92+
return;
93+
94+
// Install the hook so future style-change attempts by Avalonia can't re-strip our bits.
95+
Win32Properties.AddWndProcHookCallback(this, OnWindowsWndProc);
96+
97+
// The initial strip already happened during Show() (before this hook could catch it),
98+
// so manually OR our bits back into the current style. DWM picks them up immediately
99+
// and starts honouring Aero Snap / Win+Up / native maximize animations again.
100+
if (TryGetPlatformHandle()?.Handle is { } handle && handle != 0)
101+
{
102+
nint current = NativeMethods.GetWindowLongPtr(handle, GWL_STYLE);
103+
nint updated = (nint)((nuint)current | WS_CAPTION | WS_THICKFRAME | WS_MINIMIZEBOX | WS_MAXIMIZEBOX);
104+
if (updated != current)
105+
NativeMethods.SetWindowLongPtr(handle, GWL_STYLE, updated);
106+
}
107+
}
108+
82109
protected override void OnClosing(WindowClosingEventArgs e)
83110
{
84111
if (!_allowClose && !Settings.Get(Settings.K.DisableSystemTray))
@@ -169,6 +196,34 @@ private void SetupTitleBar()
169196
// Traffic lights sit on the left → keep the 65 px HamburgerPanel margin.
170197
ExtendClientAreaToDecorationsHint = true;
171198
ExtendClientAreaTitleBarHeightHint = -1;
199+
200+
// In fullscreen the native title bar is hidden and WindowDecorationMargin
201+
// collapses to 0, which would clip the search box and hamburger. Use a fixed
202+
// title bar height in that state, and drop the traffic-light reservation
203+
// since the traffic lights aren't shown either.
204+
this.GetObservable(WindowStateProperty).Subscribe(state =>
205+
{
206+
if (state == WindowState.FullScreen)
207+
{
208+
TitleBarGrid.ClearValue(HeightProperty);
209+
TitleBarGrid.Height = 44;
210+
MainContentGrid.ClearValue(MarginProperty);
211+
MainContentGrid.Margin = new Thickness(0, 44, 0, 0);
212+
HamburgerPanel.Margin = new Thickness(10, 0, 8, 0);
213+
}
214+
else
215+
{
216+
TitleBarGrid.Bind(HeightProperty, new Binding("WindowDecorationMargin.Top")
217+
{
218+
RelativeSource = new RelativeSource(RelativeSourceMode.FindAncestor) { AncestorType = typeof(Window) },
219+
});
220+
MainContentGrid.Bind(MarginProperty, new Binding("WindowDecorationMargin")
221+
{
222+
RelativeSource = new RelativeSource(RelativeSourceMode.FindAncestor) { AncestorType = typeof(Window) },
223+
});
224+
HamburgerPanel.Margin = new Thickness(65, 0, 8, 0);
225+
}
226+
});
172227
}
173228
else if (OperatingSystem.IsWindows())
174229
{
@@ -182,7 +237,7 @@ private void SetupTitleBar()
182237
MainContentGrid.Margin = new Thickness(0, 44, 0, 0);
183238
this.GetObservable(WindowStateProperty).Subscribe(state =>
184239
{
185-
UpdateMaximizeButtonState(state == WindowState.Maximized || _windowsRestoreBoundsBeforeManualMaximize is not null);
240+
UpdateMaximizeButtonState(state == WindowState.Maximized);
186241
});
187242
}
188243
else if (OperatingSystem.IsLinux())
@@ -301,12 +356,6 @@ private void MinimizeButton_Click(object? sender, RoutedEventArgs e)
301356

302357
private void MaximizeButton_Click(object? sender, RoutedEventArgs e)
303358
{
304-
if (OperatingSystem.IsWindows() && TryGetNativeWindowHandle() is { } handle)
305-
{
306-
ToggleWindowsManualMaximize(handle);
307-
return;
308-
}
309-
310359
WindowState = WindowState == WindowState.Maximized
311360
? WindowState.Normal
312361
: WindowState.Maximized;
@@ -323,121 +372,64 @@ private void UpdateMaximizeButtonState(bool isMaximized)
323372
CoreTools.Translate(isMaximized ? "Restore" : "Maximize"));
324373
}
325374

326-
private nint? TryGetNativeWindowHandle()
375+
private static nint OnWindowsWndProc(nint hWnd, uint msg, nint wParam, nint lParam, ref bool handled)
327376
{
328-
var handle = TryGetPlatformHandle()?.Handle ?? 0;
329-
return handle == 0 ? null : handle;
330-
}
331-
332-
private void ToggleWindowsManualMaximize(nint handle)
333-
{
334-
if (_windowsRestoreBoundsBeforeManualMaximize is { } restoreBounds)
335-
{
336-
if (SetWindowsWindowBounds(handle, restoreBounds))
377+
// Intercept SetWindowLong(GWL_STYLE, ...) attempts and OR our required bits back into
378+
// the new style before Windows accepts the change. lParam points to a STYLESTRUCT
379+
// whose styleNew member is the proposed new style. We modify it in place and let the
380+
// chain continue (no handled=true) so Avalonia / DefWindowProc still process the
381+
// (now-corrected) message.
382+
if (msg == WM_STYLECHANGING && wParam.ToInt64() == GWL_STYLE)
383+
{
384+
var ss = Marshal.PtrToStructure<NativeMethods.STYLESTRUCT>(lParam);
385+
uint preserved = ss.styleNew | WS_CAPTION | WS_THICKFRAME | WS_MINIMIZEBOX | WS_MAXIMIZEBOX;
386+
if (preserved != ss.styleNew)
337387
{
338-
_windowsRestoreBoundsBeforeManualMaximize = null;
339-
UpdateMaximizeButtonState(false);
388+
ss.styleNew = preserved;
389+
Marshal.StructureToPtr(ss, lParam, false);
340390
}
341-
return;
342-
}
343-
344-
if (NativeMethods.IsZoomed(handle))
345-
{
346-
_ = NativeMethods.ShowWindow(handle, SW_RESTORE);
347-
UpdateMaximizeButtonState(false);
348-
return;
349-
}
350-
351-
if (!NativeMethods.GetWindowRect(handle, out NativeMethods.RECT currentBounds))
352-
{
353-
Logger.Warn("Could not get the window bounds before maximizing.");
354-
return;
355-
}
356-
357-
var monitor = NativeMethods.MonitorFromWindow(handle, MONITOR_DEFAULTTONEAREST);
358-
if (monitor == 0)
359-
{
360-
Logger.Warn("Could not find a monitor for the window before maximizing.");
361-
return;
362391
}
363392

364-
var monitorInfo = new NativeMethods.MONITORINFO
365-
{
366-
cbSize = Marshal.SizeOf<NativeMethods.MONITORINFO>(),
367-
};
368-
if (!NativeMethods.GetMonitorInfo(monitor, ref monitorInfo))
369-
{
370-
Logger.Warn("Could not get monitor bounds before maximizing.");
371-
return;
372-
}
373-
374-
if (SetWindowsWindowBounds(handle, GetMaximizedWindowBounds(handle, monitorInfo.rcWork)))
375-
{
376-
_windowsRestoreBoundsBeforeManualMaximize = currentBounds;
377-
UpdateMaximizeButtonState(true);
393+
// Override the max-size / max-position Avalonia would otherwise provide. On the
394+
// primary monitor (where the taskbar lives) Avalonia's defaults can leave ptMaxSize
395+
// equal to the current window size, so Aero Snap drag-to-top "maximizes" to the same
396+
// bounds and the window appears not to resize. We always report the current monitor's
397+
// work area, which is what Windows actually uses for native maximize.
398+
// handled = true so Avalonia's own WM_GETMINMAXINFO handler can't run after us and
399+
// overwrite the values we just set.
400+
if (msg == WM_GETMINMAXINFO)
401+
{
402+
nint monitor = NativeMethods.MonitorFromWindow(hWnd, MONITOR_DEFAULTTONEAREST);
403+
if (monitor != 0)
404+
{
405+
var mi = new NativeMethods.MONITORINFO { cbSize = Marshal.SizeOf<NativeMethods.MONITORINFO>() };
406+
if (NativeMethods.GetMonitorInfo(monitor, ref mi))
407+
{
408+
var mmi = Marshal.PtrToStructure<NativeMethods.MINMAXINFO>(lParam);
409+
mmi.ptMaxPosition.X = mi.rcWork.Left - mi.rcMonitor.Left;
410+
mmi.ptMaxPosition.Y = mi.rcWork.Top - mi.rcMonitor.Top;
411+
mmi.ptMaxSize.X = mi.rcWork.Right - mi.rcWork.Left;
412+
mmi.ptMaxSize.Y = mi.rcWork.Bottom - mi.rcWork.Top;
413+
if (mmi.ptMaxTrackSize.X < mmi.ptMaxSize.X) mmi.ptMaxTrackSize.X = mmi.ptMaxSize.X;
414+
if (mmi.ptMaxTrackSize.Y < mmi.ptMaxSize.Y) mmi.ptMaxTrackSize.Y = mmi.ptMaxSize.Y;
415+
Marshal.StructureToPtr(mmi, lParam, false);
416+
handled = true;
417+
return 0;
418+
}
419+
}
378420
}
421+
return 0;
379422
}
380423

381-
private static NativeMethods.RECT GetMaximizedWindowBounds(nint handle, NativeMethods.RECT workArea)
382-
{
383-
uint dpi = NativeMethods.GetDpiForWindow(handle);
384-
if (dpi == 0)
385-
dpi = NativeMethods.GetDpiForSystem();
386-
387-
int frameX = NativeMethods.GetSystemMetricsForDpi(SM_CXSIZEFRAME, dpi)
388-
+ NativeMethods.GetSystemMetricsForDpi(SM_CXPADDEDBORDER, dpi);
389-
int frameY = NativeMethods.GetSystemMetricsForDpi(SM_CYSIZEFRAME, dpi)
390-
+ NativeMethods.GetSystemMetricsForDpi(SM_CXPADDEDBORDER, dpi);
391-
392-
frameX = Math.Max(frameX, 8);
393-
frameY = Math.Max(frameY, 8);
394-
395-
return new NativeMethods.RECT
396-
{
397-
Left = workArea.Left - frameX,
398-
Top = workArea.Top - frameY,
399-
Right = workArea.Right + frameX,
400-
Bottom = workArea.Bottom + frameY,
401-
};
402-
}
403-
404-
private static bool SetWindowsWindowBounds(nint handle, NativeMethods.RECT bounds)
405-
{
406-
bool result = NativeMethods.SetWindowPos(
407-
handle,
408-
0,
409-
bounds.Left,
410-
bounds.Top,
411-
bounds.Right - bounds.Left,
412-
bounds.Bottom - bounds.Top,
413-
SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED);
414-
if (!result)
415-
Logger.Warn($"Could not set window bounds. Win32 error: {Marshal.GetLastWin32Error()}");
416-
return result;
417-
}
418-
424+
// P/Invokes compile on any platform; they are only called from code paths guarded by
425+
// OperatingSystem.IsWindows(), so non-Windows targets never invoke user32.dll at runtime.
419426
private static class NativeMethods
420427
{
421-
[DllImport("user32.dll")]
422-
[return: MarshalAs(UnmanagedType.Bool)]
423-
public static extern bool IsZoomed(nint hWnd);
428+
[DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW", SetLastError = true)]
429+
public static extern nint GetWindowLongPtr(nint hWnd, int nIndex);
424430

425-
[DllImport("user32.dll")]
426-
[return: MarshalAs(UnmanagedType.Bool)]
427-
public static extern bool ShowWindow(nint hWnd, int nCmdShow);
428-
429-
[DllImport("user32.dll")]
430-
public static extern uint GetDpiForWindow(nint hwnd);
431-
432-
[DllImport("user32.dll")]
433-
public static extern uint GetDpiForSystem();
434-
435-
[DllImport("user32.dll")]
436-
public static extern int GetSystemMetricsForDpi(int nIndex, uint dpi);
437-
438-
[DllImport("user32.dll", SetLastError = true)]
439-
[return: MarshalAs(UnmanagedType.Bool)]
440-
public static extern bool GetWindowRect(nint hWnd, out RECT lpRect);
431+
[DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW", SetLastError = true)]
432+
public static extern nint SetWindowLongPtr(nint hWnd, int nIndex, nint dwNewLong);
441433

442434
[DllImport("user32.dll")]
443435
public static extern nint MonitorFromWindow(nint hwnd, uint dwFlags);
@@ -446,16 +438,29 @@ private static class NativeMethods
446438
[return: MarshalAs(UnmanagedType.Bool)]
447439
public static extern bool GetMonitorInfo(nint hMonitor, ref MONITORINFO lpmi);
448440

449-
[DllImport("user32.dll", SetLastError = true)]
450-
[return: MarshalAs(UnmanagedType.Bool)]
451-
public static extern bool SetWindowPos(
452-
nint hWnd,
453-
nint hWndInsertAfter,
454-
int X,
455-
int Y,
456-
int cx,
457-
int cy,
458-
uint uFlags);
441+
[StructLayout(LayoutKind.Sequential)]
442+
public struct STYLESTRUCT
443+
{
444+
public uint styleOld;
445+
public uint styleNew;
446+
}
447+
448+
[StructLayout(LayoutKind.Sequential)]
449+
public struct POINT
450+
{
451+
public int X;
452+
public int Y;
453+
}
454+
455+
[StructLayout(LayoutKind.Sequential)]
456+
public struct MINMAXINFO
457+
{
458+
public POINT ptReserved;
459+
public POINT ptMaxSize;
460+
public POINT ptMaxPosition;
461+
public POINT ptMinTrackSize;
462+
public POINT ptMaxTrackSize;
463+
}
459464

460465
[StructLayout(LayoutKind.Sequential)]
461466
public struct RECT

0 commit comments

Comments
 (0)