Skip to content

Commit 0844bd3

Browse files
committed
feat: implement Windows 11 Snap Layouts support for custom caption buttons (#1957)
1 parent 0cf7549 commit 0844bd3

File tree

5 files changed

+287
-40
lines changed

5 files changed

+287
-40
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ build/*.tar.gz
3636
build/*.deb
3737
build/*.rpm
3838
build/*.AppImage
39+
publish/
3940
SourceGit.app/
4041
build.command
4142
src/Properties/launchSettings.json

global.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44
"rollForward": "latestMajor",
55
"allowPrerelease": false
66
}
7-
}
7+
}

src/Native/Win32HitTestHelper.cs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
using Avalonia;
2+
3+
namespace SourceGit.Native
4+
{
5+
/// <summary>
6+
/// Helper for Windows 11 Snap Layouts support with custom caption buttons.
7+
/// Based on Avalonia PR #17380.
8+
/// </summary>
9+
internal static class Win32HitTestHelper
10+
{
11+
/// <summary>
12+
/// Attached property to mark UI elements with their non-client hit test result.
13+
/// This enables Windows 11 Snap Layouts on custom caption buttons.
14+
/// </summary>
15+
public static readonly AttachedProperty<HitTestValue> HitTestResultProperty =
16+
AvaloniaProperty.RegisterAttached<Visual, HitTestValue>(
17+
"HitTestResult",
18+
typeof(Win32HitTestHelper),
19+
inherits: true,
20+
defaultValue: HitTestValue.Client);
21+
22+
public static void SetHitTestResult(Visual element, HitTestValue value)
23+
=> element.SetValue(HitTestResultProperty, value);
24+
25+
public static HitTestValue GetHitTestResult(Visual element)
26+
=> element.GetValue(HitTestResultProperty);
27+
28+
/// <summary>
29+
/// Hit test values matching Windows WM_NCHITTEST return codes.
30+
/// </summary>
31+
public enum HitTestValue
32+
{
33+
Client = 1,
34+
Caption = 2,
35+
MinButton = 8,
36+
MaxButton = 9,
37+
Close = 20,
38+
}
39+
}
40+
}

src/Native/Windows.cs

Lines changed: 233 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System;
1+
using System;
22
using System.Collections.Generic;
33
using System.Diagnostics;
44
using System.IO;
@@ -9,8 +9,10 @@
99

1010
using Avalonia;
1111
using Avalonia.Controls;
12+
using Avalonia.Input;
1213
using Avalonia.Platform;
1314
using Avalonia.Threading;
15+
using Avalonia.VisualTree;
1416

1517
namespace SourceGit.Native
1618
{
@@ -69,51 +71,239 @@ public void SetupWindow(Window window)
6971
window.ExtendClientAreaToDecorationsHint = true;
7072
window.Classes.Add("fix_maximized_padding");
7173

72-
Win32Properties.AddWndProcHookCallback(window, (IntPtr hWnd, uint msg, IntPtr _, IntPtr lParam, ref bool handled) =>
73-
{
74-
// Custom WM_NCHITTEST
75-
if (msg == 0x0084)
76-
{
77-
handled = true;
74+
// Track the last hovered button for proper enter/leave handling
75+
Control lastHoveredButton = null;
76+
// Track the button that received the left button down event
77+
Control pressedButton = null;
7878

79-
if (window.WindowState == WindowState.FullScreen || window.WindowState == WindowState.Maximized)
80-
return 1; // HTCLIENT
81-
82-
var p = IntPtrToPixelPoint(lParam);
83-
GetWindowRect(hWnd, out var rcWindow);
79+
Win32Properties.AddWndProcHookCallback(window, (hWnd, msg, wParam, lParam, ref handled) =>
80+
{
81+
const uint WM_NCHITTEST = 0x0084;
82+
const uint WM_NCMOUSEMOVE = 0x00A0;
83+
const uint WM_NCLBUTTONDOWN = 0x00A1;
84+
const uint WM_NCLBUTTONUP = 0x00A2;
85+
const uint WM_NCMOUSELEAVE = 0x02A2;
86+
const uint WM_MOUSEMOVE = 0x0200;
87+
const uint WM_MOUSELEAVE = 0x02A3;
8488

85-
var borderThickness = (int)(4 * window.RenderScaling);
86-
int y = 1;
87-
int x = 1;
88-
if (p.X >= rcWindow.left && p.X < rcWindow.left + borderThickness)
89-
x = 0;
90-
else if (p.X < rcWindow.right && p.X >= rcWindow.right - borderThickness)
91-
x = 2;
9289

93-
if (p.Y >= rcWindow.top && p.Y < rcWindow.top + borderThickness)
94-
y = 0;
95-
else if (p.Y < rcWindow.bottom && p.Y >= rcWindow.bottom - borderThickness)
96-
y = 2;
90+
const int HTCLIENT = 1;
91+
const int HTMINBUTTON = 8;
92+
const int HTMAXBUTTON = 9;
93+
const int HTCLOSE = 20;
9794

98-
var zone = y * 3 + x;
99-
return zone switch
100-
{
101-
0 => 13, // HTTOPLEFT
102-
1 => 12, // HTTOP
103-
2 => 14, // HTTOPRIGHT
104-
3 => 10, // HTLEFT
105-
4 => 1, // HTCLIENT
106-
5 => 11, // HTRIGHT
107-
6 => 16, // HTBOTTOMLEFT
108-
7 => 15, // HTBOTTOM
109-
_ => 17,
110-
};
95+
switch (msg)
96+
{
97+
// Handle non-client mouse move - update button hover states
98+
case WM_NCMOUSEMOVE:
99+
{
100+
var hitTest = wParam.ToInt32();
101+
102+
if (hitTest is HTMINBUTTON or HTMAXBUTTON or HTCLOSE)
103+
{
104+
var screenPoint = IntPtrToPixelPoint(lParam);
105+
GetWindowRect(hWnd, out var rcWindow);
106+
var clientPos = PointToClient(screenPoint, rcWindow);
107+
var point = new Point(clientPos.X / window.RenderScaling, clientPos.Y / window.RenderScaling);
108+
109+
var visual = window.GetVisualAt(point, v =>
110+
{
111+
if (v is IInputElement ie && (!ie.IsHitTestVisible || !ie.IsEffectivelyVisible))
112+
return false;
113+
return true;
114+
});
115+
116+
// Find the button control
117+
var button = FindParentButton(visual);
118+
119+
if (button != lastHoveredButton)
120+
{
121+
// Mouse left the previous button
122+
if (lastHoveredButton != null)
123+
{
124+
((IPseudoClasses)lastHoveredButton.Classes).Remove(":pointerover");
125+
}
126+
127+
// Mouse entered new button
128+
if (button != null)
129+
{
130+
((IPseudoClasses)button.Classes).Add(":pointerover");
131+
}
132+
133+
lastHoveredButton = button;
134+
}
135+
136+
handled = true;
137+
return IntPtr.Zero;
138+
}
139+
140+
// Mouse moved to non-button area, clear hover state
141+
if (lastHoveredButton != null)
142+
{
143+
((IPseudoClasses)lastHoveredButton.Classes).Remove(":pointerover");
144+
lastHoveredButton = null;
145+
}
146+
147+
break;
148+
}
149+
// Handle client area mouse move/leave - clear hover state if mouse enters client area
150+
case WM_NCMOUSELEAVE:
151+
case WM_MOUSEMOVE:
152+
case WM_MOUSELEAVE:
153+
{
154+
if (lastHoveredButton != null)
155+
{
156+
((IPseudoClasses)lastHoveredButton.Classes).Remove(":pointerover");
157+
lastHoveredButton = null;
158+
}
159+
160+
break;
161+
}
162+
// Handle non-client left button down - trigger button press
163+
case WM_NCLBUTTONDOWN:
164+
{
165+
var hitTest = wParam.ToInt32();
166+
167+
if (hitTest == HTMINBUTTON || hitTest == HTMAXBUTTON || hitTest == HTCLOSE)
168+
{
169+
if (lastHoveredButton != null)
170+
{
171+
((IPseudoClasses)lastHoveredButton.Classes).Add(":pressed");
172+
pressedButton = lastHoveredButton;
173+
}
174+
175+
handled = true;
176+
return IntPtr.Zero;
177+
}
178+
179+
break;
180+
}
181+
// Handle non-client left button up - trigger button click
182+
case WM_NCLBUTTONUP:
183+
{
184+
var hitTest = wParam.ToInt32();
185+
186+
if (hitTest == HTMINBUTTON || hitTest == HTMAXBUTTON || hitTest == HTCLOSE)
187+
{
188+
var screenPoint = IntPtrToPixelPoint(lParam);
189+
GetWindowRect(hWnd, out var rcWindow);
190+
var clientPos = PointToClient(screenPoint, rcWindow);
191+
var point = new Point(clientPos.X / window.RenderScaling, clientPos.Y / window.RenderScaling);
192+
193+
var visual = window.GetVisualAt(point, v =>
194+
{
195+
if (v is IInputElement ie && (!ie.IsHitTestVisible || !ie.IsEffectivelyVisible))
196+
return false;
197+
return true;
198+
});
199+
200+
var button = FindParentButton(visual);
201+
202+
if (pressedButton != null)
203+
{
204+
((IPseudoClasses)pressedButton.Classes).Remove(":pressed");
205+
}
206+
207+
// If released on the same button that was pressed, trigger click
208+
if (button != null && button == pressedButton)
209+
{
210+
// Use Dispatcher to invoke the click after WndProc returns
211+
Dispatcher.UIThread.Post(() =>
212+
{
213+
// Simulate a button click by raising the Click event
214+
button.RaiseEvent(new Avalonia.Interactivity.RoutedEventArgs(Button.ClickEvent));
215+
});
216+
}
217+
218+
// Reset the pressed button regardless of where the mouse was released
219+
pressedButton = null;
220+
221+
handled = true;
222+
return IntPtr.Zero;
223+
}
224+
225+
break;
226+
}
227+
// Custom WM_NCHITTEST
228+
case WM_NCHITTEST:
229+
{
230+
handled = true;
231+
232+
var p = IntPtrToPixelPoint(lParam);
233+
GetWindowRect(hWnd, out var rcWindow);
234+
235+
// Check caption buttons first (for Snap Layouts support)
236+
var clientPosition = PointToClient(p, rcWindow);
237+
var point = new Point(clientPosition.X / window.RenderScaling, clientPosition.Y / window.RenderScaling);
238+
var visual = window.GetVisualAt(point, v => v is not IInputElement ie || (ie.IsHitTestVisible && ie.IsEffectivelyVisible));
239+
240+
if (visual != null)
241+
{
242+
var hitTestValue = Win32HitTestHelper.GetHitTestResult(visual);
243+
244+
// Return appropriate non-client hit test value for caption buttons
245+
// This enables Windows 11 Snap Layouts
246+
switch (hitTestValue)
247+
{
248+
case Win32HitTestHelper.HitTestValue.MinButton:
249+
return HTMINBUTTON;
250+
case Win32HitTestHelper.HitTestValue.MaxButton:
251+
return HTMAXBUTTON;
252+
case Win32HitTestHelper.HitTestValue.Close:
253+
return HTCLOSE;
254+
}
255+
}
256+
257+
if (window.WindowState is WindowState.FullScreen or WindowState.Maximized)
258+
return HTCLIENT;
259+
260+
var borderThickness = (int)(4 * window.RenderScaling);
261+
var y = 1;
262+
var x = 1;
263+
if (p.X >= rcWindow.left && p.X < rcWindow.left + borderThickness)
264+
x = 0;
265+
else if (p.X < rcWindow.right && p.X >= rcWindow.right - borderThickness)
266+
x = 2;
267+
268+
if (p.Y >= rcWindow.top && p.Y < rcWindow.top + borderThickness)
269+
y = 0;
270+
else if (p.Y < rcWindow.bottom && p.Y >= rcWindow.bottom - borderThickness)
271+
y = 2;
272+
273+
var zone = y * 3 + x;
274+
var borderHitTest = zone switch
275+
{
276+
0 => 13, // HTTOPLEFT
277+
1 => 12, // HTTOP
278+
2 => 14, // HTTOPRIGHT
279+
3 => 10, // HTLEFT
280+
4 => 0, // Not on border
281+
5 => 11, // HTRIGHT
282+
6 => 16, // HTBOTTOMLEFT
283+
7 => 15, // HTBOTTOM
284+
_ => 17, // HTBOTTOMRIGHT
285+
};
286+
287+
// If we hit a window border, return immediately
288+
return borderHitTest != 0 ? borderHitTest : HTCLIENT;
289+
}
111290
}
112291

113292
return IntPtr.Zero;
114293
});
115294
}
116295

296+
private static Control FindParentButton(Visual visual)
297+
{
298+
while (visual != null)
299+
{
300+
if (visual is Button button)
301+
return button;
302+
visual = visual.GetVisualParent();
303+
}
304+
return null;
305+
}
306+
117307
public string FindGitExecutable()
118308
{
119309
var reg = Microsoft.Win32.RegistryKey.OpenBaseKey(
@@ -283,6 +473,13 @@ private PixelPoint IntPtrToPixelPoint(IntPtr param)
283473
return new PixelPoint((short)(v & 0xffff), (short)(v >> 16));
284474
}
285475

476+
private PixelPoint PointToClient(PixelPoint screenPoint, RECT windowRect)
477+
{
478+
return new PixelPoint(
479+
screenPoint.X - windowRect.left,
480+
screenPoint.Y - windowRect.top);
481+
}
482+
286483
#region EXTERNAL_EDITOR_FINDER
287484
private string FindVSCode()
288485
{

src/Views/CaptionButtons.axaml

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,26 @@
22
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
33
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
44
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
5+
xmlns:n="using:SourceGit.Native"
56
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
67
x:Class="SourceGit.Views.CaptionButtons"
78
x:Name="ThisControl">
89
<StackPanel Orientation="Horizontal">
9-
<Button Classes="caption_button" Click="MinimizeWindow" IsVisible="{Binding !#ThisControl.IsCloseButtonOnly}">
10+
<Button Classes="caption_button"
11+
Click="MinimizeWindow"
12+
IsVisible="{Binding !#ThisControl.IsCloseButtonOnly}"
13+
n:Win32HitTestHelper.HitTestResult="MinButton">
1014
<Path Width="11" Height="11" Margin="0,2,0,0" Data="{StaticResource Icons.Window.Minimize}"/>
1115
</Button>
12-
<Button Classes="caption_button max_or_restore_btn" Click="MaximizeOrRestoreWindow" IsVisible="{Binding !#ThisControl.IsCloseButtonOnly}">
16+
<Button Classes="caption_button max_or_restore_btn"
17+
Click="MaximizeOrRestoreWindow"
18+
IsVisible="{Binding !#ThisControl.IsCloseButtonOnly}"
19+
n:Win32HitTestHelper.HitTestResult="MaxButton">
1320
<Path Width="10" Height="10" Margin="0,4,0,0"/>
1421
</Button>
15-
<Button Classes="caption_button" Click="CloseWindow">
22+
<Button Classes="caption_button"
23+
Click="CloseWindow"
24+
n:Win32HitTestHelper.HitTestResult="Close">
1625
<Path Width="9" Height="9" Margin="0,4,2,0" Data="{StaticResource Icons.Window.Close}"/>
1726
</Button>
1827
</StackPanel>

0 commit comments

Comments
 (0)