From de60c777126b3f37600a32db1cb3120180b3d2fe Mon Sep 17 00:00:00 2001 From: Gadfly Date: Sun, 7 Dec 2025 02:30:21 +0800 Subject: [PATCH] feat: implement Windows 11 Snap Layouts support for custom caption buttons (#1957) --- .gitignore | 1 + global.json | 2 +- src/Native/Win32HitTestHelper.cs | 40 +++++ src/Native/Windows.cs | 297 +++++++++++++++++++++++++++---- src/Views/CaptionButtons.axaml | 15 +- 5 files changed, 314 insertions(+), 41 deletions(-) create mode 100644 src/Native/Win32HitTestHelper.cs diff --git a/.gitignore b/.gitignore index e686a5342..c73c1989b 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ build/*.tar.gz build/*.deb build/*.rpm build/*.AppImage +publish/ SourceGit.app/ build.command src/Properties/launchSettings.json diff --git a/global.json b/global.json index 32035c656..9a523dc4f 100644 --- a/global.json +++ b/global.json @@ -4,4 +4,4 @@ "rollForward": "latestMajor", "allowPrerelease": false } -} +} \ No newline at end of file diff --git a/src/Native/Win32HitTestHelper.cs b/src/Native/Win32HitTestHelper.cs new file mode 100644 index 000000000..a2de28d34 --- /dev/null +++ b/src/Native/Win32HitTestHelper.cs @@ -0,0 +1,40 @@ +using Avalonia; + +namespace SourceGit.Native +{ + /// + /// Helper for Windows 11 Snap Layouts support with custom caption buttons. + /// Based on Avalonia PR #17380. + /// + internal static class Win32HitTestHelper + { + /// + /// Attached property to mark UI elements with their non-client hit test result. + /// This enables Windows 11 Snap Layouts on custom caption buttons. + /// + public static readonly AttachedProperty HitTestResultProperty = + AvaloniaProperty.RegisterAttached( + "HitTestResult", + typeof(Win32HitTestHelper), + inherits: true, + defaultValue: HitTestValue.Client); + + public static void SetHitTestResult(Visual element, HitTestValue value) + => element.SetValue(HitTestResultProperty, value); + + public static HitTestValue GetHitTestResult(Visual element) + => element.GetValue(HitTestResultProperty); + + /// + /// Hit test values matching Windows WM_NCHITTEST return codes. + /// + public enum HitTestValue + { + Client = 1, + Caption = 2, + MinButton = 8, + MaxButton = 9, + Close = 20, + } + } +} diff --git a/src/Native/Windows.cs b/src/Native/Windows.cs index 70e017964..fbd4705ea 100644 --- a/src/Native/Windows.cs +++ b/src/Native/Windows.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; @@ -9,8 +9,10 @@ using Avalonia; using Avalonia.Controls; +using Avalonia.Input; using Avalonia.Platform; using Avalonia.Threading; +using Avalonia.VisualTree; namespace SourceGit.Native { @@ -69,49 +71,215 @@ public void SetupWindow(Window window) window.ExtendClientAreaToDecorationsHint = true; window.Classes.Add("fix_maximized_padding"); - Win32Properties.AddWndProcHookCallback(window, (IntPtr hWnd, uint msg, IntPtr _, IntPtr lParam, ref bool handled) => - { - // Custom WM_NCHITTEST - if (msg == 0x0084) - { - handled = true; - - if (window.WindowState == WindowState.FullScreen || window.WindowState == WindowState.Maximized) - return 1; // HTCLIENT + // Track the last hovered button for proper enter/leave handling + Control lastHoveredButton = null; + // Track the button that received the left button down event + Control pressedButton = null; - var p = IntPtrToPixelPoint(lParam); - GetWindowRect(hWnd, out var rcWindow); + Win32Properties.CustomWndProcHookCallback hookCallback = (hWnd, msg, wParam, lParam, ref handled) => + { + const uint WM_NCHITTEST = 0x0084; + const uint WM_NCMOUSEMOVE = 0x00A0; + const uint WM_NCLBUTTONDOWN = 0x00A1; + const uint WM_NCLBUTTONUP = 0x00A2; + const uint WM_NCMOUSELEAVE = 0x02A2; + const uint WM_MOUSEMOVE = 0x0200; + const uint WM_MOUSELEAVE = 0x02A3; - var borderThickness = (int)(4 * window.RenderScaling); - int y = 1; - int x = 1; - if (p.X >= rcWindow.left && p.X < rcWindow.left + borderThickness) - x = 0; - else if (p.X < rcWindow.right && p.X >= rcWindow.right - borderThickness) - x = 2; - if (p.Y >= rcWindow.top && p.Y < rcWindow.top + borderThickness) - y = 0; - else if (p.Y < rcWindow.bottom && p.Y >= rcWindow.bottom - borderThickness) - y = 2; + const int HTCLIENT = 1; + const int HTMINBUTTON = 8; + const int HTMAXBUTTON = 9; + const int HTCLOSE = 20; - var zone = y * 3 + x; - return zone switch - { - 0 => 13, // HTTOPLEFT - 1 => 12, // HTTOP - 2 => 14, // HTTOPRIGHT - 3 => 10, // HTLEFT - 4 => 1, // HTCLIENT - 5 => 11, // HTRIGHT - 6 => 16, // HTBOTTOMLEFT - 7 => 15, // HTBOTTOM - _ => 17, - }; + switch (msg) + { + // Handle non-client mouse move - update button hover states + case WM_NCMOUSEMOVE: + { + var hitTest = wParam.ToInt32(); + + if (hitTest is HTMINBUTTON or HTMAXBUTTON or HTCLOSE) + { + var screenPoint = IntPtrToPixelPoint(lParam); + GetWindowRect(hWnd, out var rcWindow); + + // Find the button control at the current position + var button = GetButtonAtPoint(window, screenPoint, rcWindow); + + if (button != lastHoveredButton) + { + // Mouse left the previous button + if (lastHoveredButton != null) + { + ((IPseudoClasses)lastHoveredButton.Classes).Remove(":pointerover"); + } + + // Mouse entered new button + if (button != null) + { + ((IPseudoClasses)button.Classes).Add(":pointerover"); + } + + lastHoveredButton = button; + } + + handled = true; + return IntPtr.Zero; + } + + // Mouse moved to non-button area, clear hover state + if (lastHoveredButton != null) + { + ((IPseudoClasses)lastHoveredButton.Classes).Remove(":pointerover"); + lastHoveredButton = null; + } + + break; + } + // Handle client area mouse move/leave - clear hover state if mouse enters client area + case WM_NCMOUSELEAVE: + case WM_MOUSEMOVE: + case WM_MOUSELEAVE: + { + if (lastHoveredButton != null) + { + ((IPseudoClasses)lastHoveredButton.Classes).Remove(":pointerover"); + lastHoveredButton = null; + } + + break; + } + // Handle non-client left button down - trigger button press + case WM_NCLBUTTONDOWN: + { + var hitTest = wParam.ToInt32(); + + if (hitTest == HTMINBUTTON || hitTest == HTMAXBUTTON || hitTest == HTCLOSE) + { + var screenPoint = IntPtrToPixelPoint(lParam); + GetWindowRect(hWnd, out var rcWindow); + + // Find the button control at the current position + var button = GetButtonAtPoint(window, screenPoint, rcWindow); + + if (button != null) + { + ((IPseudoClasses)button.Classes).Add(":pressed"); + pressedButton = button; + } + + handled = true; + return IntPtr.Zero; + } + + break; + } + // Handle non-client left button up - trigger button click + case WM_NCLBUTTONUP: + { + var hitTest = wParam.ToInt32(); + + if (hitTest == HTMINBUTTON || hitTest == HTMAXBUTTON || hitTest == HTCLOSE) + { + var screenPoint = IntPtrToPixelPoint(lParam); + GetWindowRect(hWnd, out var rcWindow); + + var button = GetButtonAtPoint(window, screenPoint, rcWindow); + + if (pressedButton != null) + { + ((IPseudoClasses)pressedButton.Classes).Remove(":pressed"); + } + + // If released on the same button that was pressed, trigger click + if (button != null && button == pressedButton) + { + // Use Dispatcher to invoke the click after WndProc returns + Dispatcher.UIThread.Post(() => + { + // Simulate a button click by raising the Click event + button.RaiseEvent(new Avalonia.Interactivity.RoutedEventArgs(Button.ClickEvent)); + }); + } + + // Reset the pressed button regardless of where the mouse was released + pressedButton = null; + + handled = true; + return IntPtr.Zero; + } + + break; + } + // Custom WM_NCHITTEST + case WM_NCHITTEST: + { + handled = true; + + var p = IntPtrToPixelPoint(lParam); + GetWindowRect(hWnd, out var rcWindow); + + // Check caption buttons first (for Snap Layouts support) + var hitTestValue = HitTestVisual(window, p, rcWindow); + + // Return appropriate non-client hit test value for caption buttons + // This enables Windows 11 Snap Layouts + if (IsCaptionButton(hitTestValue)) + { + var htValue = hitTestValue switch + { + Win32HitTestHelper.HitTestValue.MinButton => HTMINBUTTON, + Win32HitTestHelper.HitTestValue.MaxButton => HTMAXBUTTON, + Win32HitTestHelper.HitTestValue.Close => HTCLOSE, + _ => HTCLIENT + }; + return new IntPtr(htValue); + } + + if (window.WindowState is WindowState.FullScreen or WindowState.Maximized) + return new IntPtr(HTCLIENT); + + var borderThickness = (int)(4 * window.RenderScaling); + var y = 1; + var x = 1; + if (p.X >= rcWindow.left && p.X < rcWindow.left + borderThickness) + x = 0; + else if (p.X < rcWindow.right && p.X >= rcWindow.right - borderThickness) + x = 2; + + if (p.Y >= rcWindow.top && p.Y < rcWindow.top + borderThickness) + y = 0; + else if (p.Y < rcWindow.bottom && p.Y >= rcWindow.bottom - borderThickness) + y = 2; + + var zone = y * 3 + x; + var htResult = zone switch + { + 0 => 13, // HTTOPLEFT + 1 => 12, // HTTOP + 2 => 14, // HTTOPRIGHT + 3 => 10, // HTLEFT + 4 => 1, // HTCLIENT + 5 => 11, // HTRIGHT + 6 => 16, // HTBOTTOMLEFT + 7 => 15, // HTBOTTOM + _ => 17, // HTBOTTOMRIGHT + }; + return new IntPtr(htResult); + } } return IntPtr.Zero; - }); + }; + + Win32Properties.AddWndProcHookCallback(window, hookCallback); + + // Remove hook when window is closed + window.Closed += (_, _) => + { + Win32Properties.RemoveWndProcHookCallback(window, hookCallback); + }; } public string FindGitExecutable() @@ -283,6 +451,61 @@ private PixelPoint IntPtrToPixelPoint(IntPtr param) return new PixelPoint((short)(v & 0xffff), (short)(v >> 16)); } + private static PixelPoint PointToClient(PixelPoint screenPoint, RECT windowRect) + { + return new PixelPoint( + screenPoint.X - windowRect.left, + screenPoint.Y - windowRect.top); + } + + private static Control FindParentButton(Visual visual) + { + while (visual != null) + { + if (visual is Button button) + return button; + visual = visual.GetVisualParent(); + } + return null; + } + + private static Control GetButtonAtPoint(Window window, PixelPoint screenPoint, RECT windowRect) + { + var clientPos = PointToClient(screenPoint, windowRect); + var point = new Point(clientPos.X / window.RenderScaling, clientPos.Y / window.RenderScaling); + + var visual = window.GetVisualAt(point, v => + { + if (v is IInputElement ie && (!ie.IsHitTestVisible || !ie.IsEffectivelyVisible)) + return false; + return true; + }); + + return FindParentButton(visual); + } + + private static Win32HitTestHelper.HitTestValue HitTestVisual(Window window, PixelPoint screenPoint, RECT windowRect) + { + var clientPos = new PixelPoint(screenPoint.X - windowRect.left, screenPoint.Y - windowRect.top); + var point = new Point(clientPos.X / window.RenderScaling, clientPos.Y / window.RenderScaling); + + var visual = window.GetVisualAt(point, v => + { + if (v is IInputElement ie && (!ie.IsHitTestVisible || !ie.IsEffectivelyVisible)) + return false; + return true; + }); + + return visual != null ? Win32HitTestHelper.GetHitTestResult(visual) : Win32HitTestHelper.HitTestValue.Client; + } + + private static bool IsCaptionButton(Win32HitTestHelper.HitTestValue hitTest) + { + return hitTest is Win32HitTestHelper.HitTestValue.MinButton + or Win32HitTestHelper.HitTestValue.MaxButton + or Win32HitTestHelper.HitTestValue.Close; + } + #region EXTERNAL_EDITOR_FINDER private string FindVSCode() { diff --git a/src/Views/CaptionButtons.axaml b/src/Views/CaptionButtons.axaml index f43230e46..4304d37ee 100644 --- a/src/Views/CaptionButtons.axaml +++ b/src/Views/CaptionButtons.axaml @@ -2,17 +2,26 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:n="using:SourceGit.Native" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="SourceGit.Views.CaptionButtons" x:Name="ThisControl"> - - -