Skip to content

Commit 619957a

Browse files
feat(hosting): NoActivate + IgnorePointerInput layered-window attrs
Adds two WindowSpec flags and matching ReactorWindow runtime mutators for opt-in Win32 extended-style behaviors needed by spec 045 §2.6 tear-off floating-window previews: - NoActivate (WS_EX_NOACTIVATE) — window appears without stealing foreground activation. Matches VS tool-window / drag-preview behavior. Applied during chrome setup BEFORE Activate so the first show observes it. - IgnorePointerInput (WS_EX_TRANSPARENT) — mouse events pass THROUGH the window to whatever's underneath. Only effective on layered windows (Opacity < 1.0). Lets a translucent preview window sit on top of a drop-target overlay without blocking clicks. Both mutators mirror their values into _spec (matching SetOpacity) so a later Update() diff sees the live state. Selftest coverage (WindowModelFixtures): - WindowModel_OpacityRoundTrip — spec + mutator + WS_EX_LAYERED bit, 1.0 strips the flag, out-of-range throws - WindowModel_NoActivateRoundTrip — spec + mutator + WS_EX_NOACTIVATE bit - WindowModel_IgnorePointerInputRoundTrip — spec + mutator + WS_EX_TRANSPARENT bit All three pass on ARM64 (16/16 asserts).
1 parent dfb42fa commit 619957a

4 files changed

Lines changed: 226 additions & 0 deletions

File tree

src/Reactor/Hosting/ReactorWindow.cs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,12 @@ private void ApplyChrome(WindowSpec spec, bool isInitial)
417417
// windows pay zero layering overhead (Windows compositor fast-path).
418418
ApplyOpacity(spec.Opacity);
419419

420+
// Spec 045 §2.6 tear-off — NoActivate must be applied before
421+
// Activate fires (in MountAndActivate) so the window's first show
422+
// observes the flag. Re-applied on Update so flips stick.
423+
SetNoActivate(spec.NoActivate);
424+
SetIgnorePointerInput(spec.IgnorePointerInput);
425+
420426
// Owner relationship — only meaningful at initial apply time.
421427
// Subsequent Update calls do not re-parent (changing ownership of a
422428
// realized window has no AppWindow API and is rarely the right thing
@@ -1014,10 +1020,57 @@ private void ApplyOpacity(double opacity)
10141020
_ = NativeOpacity.SetLayeredWindowAttributes(_hwnd, 0, alpha, NativeOpacity.LWA_ALPHA);
10151021
}
10161022

1023+
/// <summary>
1024+
/// Toggle the <c>WS_EX_NOACTIVATE</c> extended style on the underlying
1025+
/// HWND. When set, the window appears without stealing foreground
1026+
/// activation (matches VS tool-window / drag-preview behavior).
1027+
/// UI-thread only. No-op after disposal.
1028+
/// </summary>
1029+
public void SetNoActivate(bool noActivate)
1030+
{
1031+
ThreadAffinity.ThrowIfNotOnUIThread(nameof(SetNoActivate));
1032+
if (_disposed) return;
1033+
var current = NativeOpacity.GetWindowLongPtr(_hwnd, NativeOpacity.GWL_EXSTYLE);
1034+
long bits = (long)current;
1035+
long updated = noActivate
1036+
? bits | NativeOpacity.WS_EX_NOACTIVATE
1037+
: bits & ~NativeOpacity.WS_EX_NOACTIVATE;
1038+
if (updated != bits)
1039+
_ = NativeOpacity.SetWindowLongPtr(_hwnd, NativeOpacity.GWL_EXSTYLE, (nint)updated);
1040+
// Mirror into _spec so Update() diffs see the live value.
1041+
var prev = Volatile.Read(ref _spec);
1042+
if (prev.NoActivate != noActivate)
1043+
Volatile.Write(ref _spec, prev with { NoActivate = noActivate });
1044+
}
1045+
1046+
/// <summary>
1047+
/// Toggle the <c>WS_EX_TRANSPARENT</c> extended style on the underlying
1048+
/// HWND. When set, mouse events pass THROUGH the window to whatever's
1049+
/// underneath. Only effective on layered windows (i.e. paired with
1050+
/// <see cref="SetOpacity"/> &lt; 1.0). UI-thread only. No-op after disposal.
1051+
/// </summary>
1052+
public void SetIgnorePointerInput(bool ignore)
1053+
{
1054+
ThreadAffinity.ThrowIfNotOnUIThread(nameof(SetIgnorePointerInput));
1055+
if (_disposed) return;
1056+
var current = NativeOpacity.GetWindowLongPtr(_hwnd, NativeOpacity.GWL_EXSTYLE);
1057+
long bits = (long)current;
1058+
long updated = ignore
1059+
? bits | NativeOpacity.WS_EX_TRANSPARENT
1060+
: bits & ~NativeOpacity.WS_EX_TRANSPARENT;
1061+
if (updated != bits)
1062+
_ = NativeOpacity.SetWindowLongPtr(_hwnd, NativeOpacity.GWL_EXSTYLE, (nint)updated);
1063+
var prev = Volatile.Read(ref _spec);
1064+
if (prev.IgnorePointerInput != ignore)
1065+
Volatile.Write(ref _spec, prev with { IgnorePointerInput = ignore });
1066+
}
1067+
10171068
private static class NativeOpacity
10181069
{
10191070
public const int GWL_EXSTYLE = -20;
10201071
public const long WS_EX_LAYERED = 0x00080000;
1072+
public const long WS_EX_NOACTIVATE = 0x08000000;
1073+
public const long WS_EX_TRANSPARENT = 0x00000020;
10211074
public const uint LWA_ALPHA = 0x00000002;
10221075

10231076
[DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW", SetLastError = true)]

src/Reactor/Hosting/WindowSpec.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,24 @@ public sealed record WindowSpec
115115
/// </summary>
116116
public double Opacity { get; init; } = 1.0;
117117

118+
/// <summary>
119+
/// Apply the Win32 <c>WS_EX_NOACTIVATE</c> extended style so the window
120+
/// appears without stealing foreground activation. Matches VS tool-window
121+
/// / drag-preview behavior. Default false. The flag is applied during
122+
/// chrome setup (before Activate) so the first show observes it.
123+
/// </summary>
124+
public bool NoActivate { get; init; }
125+
126+
/// <summary>
127+
/// Apply the Win32 <c>WS_EX_TRANSPARENT</c> extended style so mouse events
128+
/// pass THROUGH the window to whatever's underneath. Requires
129+
/// <see cref="Opacity"/> &lt; 1.0 (the OS only honors transparent on
130+
/// layered windows). Default false. Used by spec 045 §2.6 tear-off so
131+
/// the drag preview doesn't block clicks on drop-target overlays
132+
/// below it.
133+
/// </summary>
134+
public bool IgnorePointerInput { get; init; }
135+
118136
/// <summary>
119137
/// Construct with explicit field values. Validation runs after the record
120138
/// auto-init.

tests/Reactor.AppTests.Host/SelfTest/Fixtures/WindowModelFixtures.cs

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using Microsoft.UI.Xaml;
66
using static Microsoft.UI.Reactor.Factories;
77
using System.Reflection;
8+
using System.Runtime.InteropServices;
89

910
namespace Microsoft.UI.Reactor.AppTests.Host.SelfTest.Fixtures;
1011

@@ -492,4 +493,152 @@ public override async Task RunAsync()
492493
H.Check("WindowMut_OwnedRemoved", child is null || !parent.OwnedWindows.Contains(child));
493494
}
494495
}
496+
497+
// ════════════════════════════════════════════════════════════════════
498+
// Layered-window attrs — Opacity / NoActivate / IgnorePointerInput
499+
//
500+
// Verifies the Win32 WS_EX_LAYERED / WS_EX_NOACTIVATE / WS_EX_TRANSPARENT
501+
// extended-style bits flip via WindowSpec at construction and via the
502+
// runtime mutators (SetOpacity / SetNoActivate / SetIgnorePointerInput).
503+
// Each fixture reads GWL_EXSTYLE directly to confirm the OS-level flag
504+
// is set; the managed spec mirror is exercised in parallel so the next
505+
// Update() diff sees the live values. (spec 045 §2.6 tear-off
506+
// foundation primitives.)
507+
// ════════════════════════════════════════════════════════════════════
508+
509+
private static class WindowAttrInterop
510+
{
511+
public const int GWL_EXSTYLE = -20;
512+
public const long WS_EX_LAYERED = 0x00080000;
513+
public const long WS_EX_NOACTIVATE = 0x08000000;
514+
public const long WS_EX_TRANSPARENT = 0x00000020;
515+
516+
[DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW", SetLastError = true)]
517+
public static extern nint GetWindowLongPtr(nint hWnd, int nIndex);
518+
519+
public static long ExStyle(nint hwnd) => (long)GetWindowLongPtr(hwnd, GWL_EXSTYLE);
520+
public static bool HasFlag(nint hwnd, long flag) => (ExStyle(hwnd) & flag) != 0;
521+
}
522+
523+
private static nint HwndOf(ReactorWindow win) =>
524+
WinRT.Interop.WindowNative.GetWindowHandle(win.NativeWindow);
525+
526+
internal class WindowOpacityRoundTrip(Harness h) : SelfTestFixtureBase(h)
527+
{
528+
public override async Task RunAsync()
529+
{
530+
EnsureUIDispatcher();
531+
532+
// Spec-set opacity at construction → WS_EX_LAYERED on.
533+
var win = await OpenAndSettle(
534+
new WindowSpec { Title = "Opacity Test", Width = 320, Height = 200, Opacity = 0.5 },
535+
() => new StubComponent());
536+
try
537+
{
538+
var hwnd = HwndOf(win);
539+
H.Check("WindowAttr_Opacity_SpecValue", Math.Abs(win.Spec.Opacity - 0.5) < 0.0001);
540+
H.Check("WindowAttr_Opacity_LayeredOnAtSpec",
541+
WindowAttrInterop.HasFlag(hwnd, WindowAttrInterop.WS_EX_LAYERED));
542+
543+
// Mutator round-trip to 0.25 → still layered, spec updated.
544+
win.SetOpacity(0.25);
545+
await Harness.Render();
546+
H.Check("WindowAttr_Opacity_SpecAfterMutator", Math.Abs(win.Spec.Opacity - 0.25) < 0.0001);
547+
H.Check("WindowAttr_Opacity_LayeredOnAfterMutator",
548+
WindowAttrInterop.HasFlag(hwnd, WindowAttrInterop.WS_EX_LAYERED));
549+
550+
// 1.0 strips WS_EX_LAYERED (compositor fast-path restored).
551+
win.SetOpacity(1.0);
552+
await Harness.Render();
553+
H.Check("WindowAttr_Opacity_LayeredOffAt1",
554+
!WindowAttrInterop.HasFlag(hwnd, WindowAttrInterop.WS_EX_LAYERED));
555+
556+
// Out-of-range throws.
557+
bool threw = false;
558+
try { win.SetOpacity(1.5); }
559+
catch (ArgumentOutOfRangeException) { threw = true; }
560+
H.Check("WindowAttr_Opacity_OutOfRangeThrows", threw);
561+
}
562+
finally
563+
{
564+
await CloseAndSettle(win);
565+
}
566+
}
567+
}
568+
569+
internal class WindowNoActivateRoundTrip(Harness h) : SelfTestFixtureBase(h)
570+
{
571+
public override async Task RunAsync()
572+
{
573+
EnsureUIDispatcher();
574+
575+
// Spec-set NoActivate → flag on at first show.
576+
var win = await OpenAndSettle(
577+
new WindowSpec { Title = "NoActivate Test", Width = 320, Height = 200, NoActivate = true },
578+
() => new StubComponent());
579+
try
580+
{
581+
var hwnd = HwndOf(win);
582+
H.Check("WindowAttr_NoActivate_SpecValue", win.Spec.NoActivate);
583+
H.Check("WindowAttr_NoActivate_FlagOnAtSpec",
584+
WindowAttrInterop.HasFlag(hwnd, WindowAttrInterop.WS_EX_NOACTIVATE));
585+
586+
// Mutator off → flag clears, spec mirrors.
587+
win.SetNoActivate(false);
588+
H.Check("WindowAttr_NoActivate_SpecAfterMutator", !win.Spec.NoActivate);
589+
H.Check("WindowAttr_NoActivate_FlagOffAfterMutator",
590+
!WindowAttrInterop.HasFlag(hwnd, WindowAttrInterop.WS_EX_NOACTIVATE));
591+
592+
// Mutator back on → flag returns.
593+
win.SetNoActivate(true);
594+
H.Check("WindowAttr_NoActivate_FlagOnAgain",
595+
WindowAttrInterop.HasFlag(hwnd, WindowAttrInterop.WS_EX_NOACTIVATE));
596+
}
597+
finally
598+
{
599+
await CloseAndSettle(win);
600+
}
601+
}
602+
}
603+
604+
internal class WindowIgnorePointerInputRoundTrip(Harness h) : SelfTestFixtureBase(h)
605+
{
606+
public override async Task RunAsync()
607+
{
608+
EnsureUIDispatcher();
609+
610+
// Spec-set IgnorePointerInput together with Opacity<1.0 (the OS
611+
// only honors transparent on layered windows).
612+
var win = await OpenAndSettle(
613+
new WindowSpec
614+
{
615+
Title = "IgnorePointer Test",
616+
Width = 320,
617+
Height = 200,
618+
Opacity = 0.5,
619+
IgnorePointerInput = true,
620+
},
621+
() => new StubComponent());
622+
try
623+
{
624+
var hwnd = HwndOf(win);
625+
H.Check("WindowAttr_IgnorePointer_SpecValue", win.Spec.IgnorePointerInput);
626+
H.Check("WindowAttr_IgnorePointer_FlagOnAtSpec",
627+
WindowAttrInterop.HasFlag(hwnd, WindowAttrInterop.WS_EX_TRANSPARENT));
628+
629+
win.SetIgnorePointerInput(false);
630+
H.Check("WindowAttr_IgnorePointer_SpecAfterMutator", !win.Spec.IgnorePointerInput);
631+
H.Check("WindowAttr_IgnorePointer_FlagOffAfterMutator",
632+
!WindowAttrInterop.HasFlag(hwnd, WindowAttrInterop.WS_EX_TRANSPARENT));
633+
634+
win.SetIgnorePointerInput(true);
635+
H.Check("WindowAttr_IgnorePointer_FlagOnAgain",
636+
WindowAttrInterop.HasFlag(hwnd, WindowAttrInterop.WS_EX_TRANSPARENT));
637+
}
638+
finally
639+
{
640+
await CloseAndSettle(win);
641+
}
642+
}
643+
}
495644
}

tests/Reactor.AppTests.Host/SelfTest/SelfTestFixtureRegistry.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -890,6 +890,9 @@ internal static class SelfTestFixtureRegistry
890890
"WindowModel_TrayIconRoundTrip",
891891
"WindowModel_UseOpenWindowReusesByKey",
892892
"WindowModel_MutatorsOwnerAndGuards",
893+
"WindowModel_OpacityRoundTrip",
894+
"WindowModel_NoActivateRoundTrip",
895+
"WindowModel_IgnorePointerInputRoundTrip",
893896
// Spec 045 §2.19 — Phase-1 wrapper-based Docking_* smoke fixtures
894897
// were retired with the XAML wrapper at the §2.29 review gate.
895898
// NativeDocking_* below covers the same surface against the P2
@@ -1939,6 +1942,9 @@ internal static class SelfTestFixtureRegistry
19391942
"WindowModel_TrayIconRoundTrip" => new WindowModelFixtures.TrayIconRoundTrip(harness),
19401943
"WindowModel_UseOpenWindowReusesByKey" => new WindowModelFixtures.UseOpenWindowReusesByKey(harness),
19411944
"WindowModel_MutatorsOwnerAndGuards" => new WindowModelFixtures.WindowMutatorsOwnerAndGuards(harness),
1945+
"WindowModel_OpacityRoundTrip" => new WindowModelFixtures.WindowOpacityRoundTrip(harness),
1946+
"WindowModel_NoActivateRoundTrip" => new WindowModelFixtures.WindowNoActivateRoundTrip(harness),
1947+
"WindowModel_IgnorePointerInputRoundTrip" => new WindowModelFixtures.WindowIgnorePointerInputRoundTrip(harness),
19421948
// Spec 045 §2.19 — Phase-1 DockingSmokeFixtures retired alongside
19431949
// the XAML wrapper unhooking. NativeDockingSmokeFixtures (below)
19441950
// covers the same mount/update/unmount surface against the P2

0 commit comments

Comments
 (0)