Skip to content

Commit de60c77

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

File tree

5 files changed

+314
-41
lines changed

5 files changed

+314
-41
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: 260 additions & 37 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,49 +71,215 @@ 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;
78-
79-
if (window.WindowState == WindowState.FullScreen || window.WindowState == WindowState.Maximized)
80-
return 1; // HTCLIENT
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;
8178

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

113273
return IntPtr.Zero;
114-
});
274+
};
275+
276+
Win32Properties.AddWndProcHookCallback(window, hookCallback);
277+
278+
// Remove hook when window is closed
279+
window.Closed += (_, _) =>
280+
{
281+
Win32Properties.RemoveWndProcHookCallback(window, hookCallback);
282+
};
115283
}
116284

117285
public string FindGitExecutable()
@@ -283,6 +451,61 @@ private PixelPoint IntPtrToPixelPoint(IntPtr param)
283451
return new PixelPoint((short)(v & 0xffff), (short)(v >> 16));
284452
}
285453

454+
private static PixelPoint PointToClient(PixelPoint screenPoint, RECT windowRect)
455+
{
456+
return new PixelPoint(
457+
screenPoint.X - windowRect.left,
458+
screenPoint.Y - windowRect.top);
459+
}
460+
461+
private static Control FindParentButton(Visual visual)
462+
{
463+
while (visual != null)
464+
{
465+
if (visual is Button button)
466+
return button;
467+
visual = visual.GetVisualParent();
468+
}
469+
return null;
470+
}
471+
472+
private static Control GetButtonAtPoint(Window window, PixelPoint screenPoint, RECT windowRect)
473+
{
474+
var clientPos = PointToClient(screenPoint, windowRect);
475+
var point = new Point(clientPos.X / window.RenderScaling, clientPos.Y / window.RenderScaling);
476+
477+
var visual = window.GetVisualAt(point, v =>
478+
{
479+
if (v is IInputElement ie && (!ie.IsHitTestVisible || !ie.IsEffectivelyVisible))
480+
return false;
481+
return true;
482+
});
483+
484+
return FindParentButton(visual);
485+
}
486+
487+
private static Win32HitTestHelper.HitTestValue HitTestVisual(Window window, PixelPoint screenPoint, RECT windowRect)
488+
{
489+
var clientPos = new PixelPoint(screenPoint.X - windowRect.left, screenPoint.Y - windowRect.top);
490+
var point = new Point(clientPos.X / window.RenderScaling, clientPos.Y / window.RenderScaling);
491+
492+
var visual = window.GetVisualAt(point, v =>
493+
{
494+
if (v is IInputElement ie && (!ie.IsHitTestVisible || !ie.IsEffectivelyVisible))
495+
return false;
496+
return true;
497+
});
498+
499+
return visual != null ? Win32HitTestHelper.GetHitTestResult(visual) : Win32HitTestHelper.HitTestValue.Client;
500+
}
501+
502+
private static bool IsCaptionButton(Win32HitTestHelper.HitTestValue hitTest)
503+
{
504+
return hitTest is Win32HitTestHelper.HitTestValue.MinButton
505+
or Win32HitTestHelper.HitTestValue.MaxButton
506+
or Win32HitTestHelper.HitTestValue.Close;
507+
}
508+
286509
#region EXTERNAL_EDITOR_FINDER
287510
private string FindVSCode()
288511
{

0 commit comments

Comments
 (0)