Browse Source

Properly handle nc hittest for caption buttons (#17380)

* Set e.Handle=true on caption button click event

* Return HTCLOSE, HTMINBUTTON and HTMAXBUTTON results on corresponding caption buttons

* Redirect fake-client input for caption buttons

* Introduce Win32Properties.NonClientHitTestResultProperty instead of hardcoding

* Changes after revie
pull/17516/head
Max Katz 1 year ago
committed by GitHub
parent
commit
214ffa7345
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 32
      src/Avalonia.Controls/Chrome/CaptionButtons.cs
  2. 29
      src/Avalonia.Controls/Platform/Win32Properties.cs
  3. 9
      src/Avalonia.Themes.Fluent/Controls/CaptionButtons.xaml
  4. 6
      src/Avalonia.Themes.Fluent/Controls/TitleBar.xaml
  5. 9
      src/Avalonia.Themes.Simple/Controls/CaptionButtons.xaml
  6. 7
      src/Avalonia.Themes.Simple/Controls/TitleBar.xaml
  7. 132
      src/Windows/Avalonia.Win32/WindowImpl.CustomCaptionProc.cs

32
src/Avalonia.Controls/Chrome/CaptionButtons.cs

@ -15,10 +15,10 @@ namespace Avalonia.Controls.Chrome
[PseudoClasses(":minimized", ":normal", ":maximized", ":fullscreen")]
public class CaptionButtons : TemplatedControl
{
private const string PART_CloseButton = "PART_CloseButton";
private const string PART_RestoreButton = "PART_RestoreButton";
private const string PART_MinimizeButton = "PART_MinimizeButton";
private const string PART_FullScreenButton = "PART_FullScreenButton";
internal const string PART_CloseButton = "PART_CloseButton";
internal const string PART_RestoreButton = "PART_RestoreButton";
internal const string PART_MinimizeButton = "PART_MinimizeButton";
internal const string PART_FullScreenButton = "PART_FullScreenButton";
private Button? _restoreButton;
private IDisposable? _disposables;
@ -102,24 +102,40 @@ namespace Avalonia.Controls.Chrome
if (e.NameScope.Find<Button>(PART_CloseButton) is { } closeButton)
{
closeButton.Click += (sender, e) => OnClose();
closeButton.Click += (_, args) =>
{
OnClose();
args.Handled = true;
};
}
if (e.NameScope.Find<Button>(PART_RestoreButton) is { } restoreButton)
{
restoreButton.Click += (sender, e) => OnRestore();
restoreButton.Click += (_, args) =>
{
OnRestore();
args.Handled = true;
};
restoreButton.IsEnabled = HostWindow?.CanResize ?? true;
_restoreButton = restoreButton;
}
if (e.NameScope.Find<Button>(PART_MinimizeButton) is { } minimizeButton)
{
minimizeButton.Click += (sender, e) => OnMinimize();
minimizeButton.Click += (_, args) =>
{
OnMinimize();
args.Handled = true;
};
}
if (e.NameScope.Find<Button>(PART_FullScreenButton) is { } fullScreenButton)
{
fullScreenButton.Click += (sender, e) => OnToggleFullScreen();
fullScreenButton.Click += (_, args) =>
{
OnToggleFullScreen();
args.Handled = true;
};
}
}
}

29
src/Avalonia.Controls/Platform/Win32Properties.cs

@ -5,6 +5,7 @@ using System.Text;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Controls.Platform;
using Avalonia.Input;
using Avalonia.Metadata;
using Avalonia.Platform;
using static Avalonia.Controls.Platform.IWin32OptionsTopLevelImpl;
@ -70,5 +71,33 @@ namespace Avalonia.Controls
toplevelImpl.WndProcHookCallback -= callback;
}
}
public static readonly AttachedProperty<Win32HitTestValue> NonClientHitTestResultProperty =
AvaloniaProperty.RegisterAttached<Visual, Win32HitTestValue>(
"NonClientHitTestResult",
typeof(Win32Properties),
inherits: true,
defaultValue: Win32HitTestValue.Client);
public static void SetNonClientHitTestResult(Visual obj, Win32HitTestValue value) => obj.SetValue(NonClientHitTestResultProperty, value);
public static Win32HitTestValue GetNonClientHitTestResult(Visual obj) => obj.GetValue(NonClientHitTestResultProperty);
public enum Win32HitTestValue
{
Nowhere = 0,
Client = 1,
Caption = 2,
MinButton = 8,
MaxButton = 9,
Left = 10,
Right = 11,
Top = 12,
TopLeft = 13,
TopRight = 14,
Bottom = 15,
BottomLeft = 16,
BottomRight = 17,
Close = 20,
}
}
}

9
src/Avalonia.Themes.Fluent/Controls/CaptionButtons.xaml

@ -62,7 +62,8 @@
</Button>
<Button x:Name="PART_MinimizeButton"
Theme="{StaticResource FluentCaptionButton}"
AutomationProperties.Name="Minimize">
AutomationProperties.Name="Minimize"
Win32Properties.NonClientHitTestResult="MinButton">
<Viewbox Width="11" Margin="2">
<Path Stretch="UniformToFill"
Fill="{TemplateBinding Foreground}"
@ -71,7 +72,8 @@
</Button>
<Button x:Name="PART_RestoreButton"
Theme="{StaticResource FluentCaptionButton}"
AutomationProperties.Name="Maximize">
AutomationProperties.Name="Maximize"
Win32Properties.NonClientHitTestResult="MaxButton">
<Viewbox Width="11" Margin="2">
<Viewbox.RenderTransform>
<RotateTransform Angle="-90" />
@ -86,7 +88,8 @@
Background="#ffe81123"
BorderBrush="#fff1707a"
Theme="{StaticResource FluentCaptionButton}"
AutomationProperties.Name="Close">
AutomationProperties.Name="Close"
Win32Properties.NonClientHitTestResult="Close">
<Viewbox Width="11" Margin="2">
<Path Stretch="UniformToFill"
Fill="{TemplateBinding Foreground}"

6
src/Avalonia.Themes.Fluent/Controls/TitleBar.xaml

@ -22,11 +22,13 @@
<Panel x:Name="PART_Container">
<Border x:Name="PART_Background"
Background="{TemplateBinding Background}"
IsHitTestVisible="False"/>
IsHitTestVisible="False"
Win32Properties.NonClientHitTestResult="Caption" />
<CaptionButtons x:Name="PART_CaptionButtons"
VerticalAlignment="Top"
HorizontalAlignment="Right"
Foreground="{TemplateBinding Foreground}" />
Foreground="{TemplateBinding Foreground}"
Win32Properties.NonClientHitTestResult="Client" />
</Panel>
</Panel>
</ControlTemplate>

9
src/Avalonia.Themes.Simple/Controls/CaptionButtons.xaml

@ -66,7 +66,8 @@
</Button>
<Button x:Name="PART_MinimizeButton"
Theme="{StaticResource SimpleCaptionButton}"
AutomationProperties.Name="Minimize">
AutomationProperties.Name="Minimize"
Win32Properties.NonClientHitTestResult="MinButton">
<Viewbox Width="11"
Margin="2">
<Path Data="M2048 1229v-205h-2048v205h2048z"
@ -76,7 +77,8 @@
</Button>
<Button x:Name="PART_RestoreButton"
Theme="{StaticResource SimpleCaptionButton}"
AutomationProperties.Name="Maximize">
AutomationProperties.Name="Maximize"
Win32Properties.NonClientHitTestResult="MaxButton">
<Viewbox Width="11"
Margin="2">
<Viewbox.RenderTransform>
@ -92,7 +94,8 @@
Background="#ffe81123"
BorderBrush="#fff1707a"
Theme="{StaticResource SimpleCaptionButton}"
AutomationProperties.Name="Close">
AutomationProperties.Name="Close"
Win32Properties.NonClientHitTestResult="Close">
<Viewbox Width="11"
Margin="2">
<Path Data="M1169 1024l879 -879l-145 -145l-879 879l-879 -879l-145 145l879 879l-879 879l145 145l879 -879l879 879l145 -145z"

7
src/Avalonia.Themes.Simple/Controls/TitleBar.xaml

@ -24,11 +24,14 @@
VerticalAlignment="Top" />
<Panel x:Name="PART_Container">
<Border x:Name="PART_Background"
Background="{TemplateBinding Background}" />
Background="{TemplateBinding Background}"
IsHitTestVisible="False"
Win32Properties.NonClientHitTestResult="Caption" />
<CaptionButtons x:Name="PART_CaptionButtons"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Foreground="{TemplateBinding Foreground}" />
Foreground="{TemplateBinding Foreground}"
Win32Properties.NonClientHitTestResult="Client" />
</Panel>
</Panel>
</ControlTemplate>

132
src/Windows/Avalonia.Win32/WindowImpl.CustomCaptionProc.cs

@ -1,6 +1,7 @@
using System;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Input.Raw;
using Avalonia.VisualTree;
using static Avalonia.Win32.Interop.UnmanagedMethods;
@ -8,7 +9,6 @@ namespace Avalonia.Win32
{
internal partial class WindowImpl
{
// Hit test the frame for resizing and moving.
private HitTestValues HitTestNCA(IntPtr hWnd, IntPtr wParam, IntPtr lParam)
{
// Get the point coordinates for the hit test (screen space).
@ -65,9 +65,10 @@ namespace Avalonia.Win32
uRow = 2;
}
var captionAreaHitTest = WindowState == WindowState.FullScreen ? HitTestValues.HTNOWHERE : HitTestValues.HTCAPTION;
ReadOnlySpan<HitTestValues> hitZones = stackalloc HitTestValues[]
{
HitTestValues.HTTOPLEFT, onResizeBorder ? HitTestValues.HTTOP : HitTestValues.HTCAPTION,
HitTestValues.HTTOPLEFT, onResizeBorder ? HitTestValues.HTTOP : captionAreaHitTest,
HitTestValues.HTTOPRIGHT, HitTestValues.HTLEFT, HitTestValues.HTNOWHERE, HitTestValues.HTRIGHT,
HitTestValues.HTBOTTOMLEFT, HitTestValues.HTBOTTOM, HitTestValues.HTBOTTOMRIGHT
};
@ -79,6 +80,7 @@ namespace Avalonia.Win32
protected virtual IntPtr CustomCaptionProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam, ref bool callDwp)
{
RawPointerEventArgs? e = null;
IntPtr lRet = IntPtr.Zero;
callDwp = !DwmDefWindowProc(hWnd, msg, wParam, lParam, ref lRet);
@ -92,47 +94,121 @@ namespace Avalonia.Win32
case WindowsMessage.WM_NCHITTEST:
if (lRet == IntPtr.Zero)
{
if(WindowState == WindowState.FullScreen)
{
return (IntPtr)HitTestValues.HTCLIENT;
}
var hittestResult = HitTestNCA(hWnd, wParam, lParam);
lRet = (IntPtr)hittestResult;
if (hittestResult == HitTestValues.HTCAPTION)
if (hittestResult is HitTestValues.HTNOWHERE or HitTestValues.HTCAPTION)
{
var position = PointToClient(PointFromLParam(lParam));
if (_owner is Window window)
var visualHittestResult = HitTestVisual(lParam);
if (visualHittestResult != HitTestValues.HTNOWHERE)
{
var visual = window.GetVisualAt(position, x =>
{
if (x is IInputElement ie && (!ie.IsHitTestVisible || !ie.IsEffectivelyVisible))
{
return false;
}
return true;
});
if (visual != null)
{
hittestResult = HitTestValues.HTCLIENT;
lRet = (IntPtr)hittestResult;
}
hittestResult = visualHittestResult;
}
}
if (hittestResult != HitTestValues.HTNOWHERE)
{
lRet = (IntPtr)hittestResult;
callDwp = false;
}
}
break;
// Normally, Avalonia doesn't handles non-client input as a special NonClientLeftButtonDown, ignoring move and up events.
// What makes it a problem, Avalonia has to mark templated caption buttons as a non-client area.
// Meaning, these buttons no longer can accept normal client input.
// These messages are needed to explicitly fake this normal client input from non-client messages.
// For both WM_NCMOUSE and WM_NCPOINTERUPDATE
case WindowsMessage.WM_NCMOUSEMOVE when !IsMouseInPointerEnabled:
case WindowsMessage.WM_NCLBUTTONDOWN when !IsMouseInPointerEnabled:
case WindowsMessage.WM_NCLBUTTONUP when !IsMouseInPointerEnabled:
if (lRet == IntPtr.Zero
&& ShouldRedirectNonClientInput(hWnd, wParam, lParam))
{
e = new RawPointerEventArgs(
_mouseDevice,
unchecked((uint)GetMessageTime()),
Owner,
(WindowsMessage)msg switch
{
WindowsMessage.WM_NCMOUSEMOVE => RawPointerEventType.Move,
WindowsMessage.WM_NCLBUTTONDOWN => RawPointerEventType.LeftButtonDown,
WindowsMessage.WM_NCLBUTTONUP => RawPointerEventType.LeftButtonUp,
_ => throw new ArgumentOutOfRangeException(nameof(msg), msg, null)
},
PointToClient(PointFromLParam(lParam)),
RawInputModifiers.None);
}
break;
case WindowsMessage.WM_NCPOINTERUPDATE when _wmPointerEnabled:
case WindowsMessage.WM_NCPOINTERDOWN when _wmPointerEnabled:
case WindowsMessage.WM_NCPOINTERUP when _wmPointerEnabled:
if (lRet == IntPtr.Zero
&& ShouldRedirectNonClientInput(hWnd, wParam, lParam))
{
uint timestamp = 0;
GetDevicePointerInfo(wParam, out var device, out var info, out var point, out var modifiers, ref timestamp);
var eventType = (WindowsMessage)msg switch
{
WindowsMessage.WM_NCPOINTERUPDATE => RawPointerEventType.Move,
WindowsMessage.WM_NCPOINTERDOWN => RawPointerEventType.LeftButtonDown,
WindowsMessage.WM_NCPOINTERUP => RawPointerEventType.LeftButtonUp,
_ => throw new ArgumentOutOfRangeException(nameof(msg), msg, null)
};
e = CreatePointerArgs(device, timestamp, eventType, point, modifiers, info.pointerId);
}
break;
}
if (e is not null && Input is not null)
{
Input(e);
if (e.Handled)
{
callDwp = false;
return IntPtr.Zero;
}
}
return lRet;
}
private HitTestValues HitTestVisual(IntPtr lParam)
{
var position = PointToClient(PointFromLParam(lParam));
if (_owner is Window window)
{
var visual = window.GetVisualAt(position, x =>
{
if (x is IInputElement ie && (!ie.IsHitTestVisible || !ie.IsEffectivelyVisible))
{
return false;
}
return true;
});
if (visual != null)
{
var hitTest = Win32Properties.GetNonClientHitTestResult(visual);
return (HitTestValues)hitTest;
}
}
return HitTestValues.HTNOWHERE;
}
private bool ShouldRedirectNonClientInput(IntPtr hWnd, IntPtr wParam, IntPtr lParam)
{
// We touched frame borders or caption, don't redirect.
if (HitTestNCA(hWnd, wParam, lParam) is not (HitTestValues.HTNOWHERE or HitTestValues.HTCAPTION))
return false;
// Redirect only for buttons.
return HitTestVisual(lParam)
is HitTestValues.HTMINBUTTON
or HitTestValues.HTMAXBUTTON
or HitTestValues.HTCLOSE
or HitTestValues.HTHELP
or HitTestValues.HTMENU
or HitTestValues.HTSYSMENU;
}
}
}

Loading…
Cancel
Save