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">
-