From 7b8dadea0ba2dbc7fa9a441ea54347565a193c76 Mon Sep 17 00:00:00 2001 From: Denis Poledna Date: Thu, 14 May 2026 15:49:14 +0200 Subject: [PATCH 01/21] feat: replace DwmThumbnail with WGC+Composition pipeline DwmRegisterThumbnail can't do rounded corners or transforms; migrate to Windows.Graphics.Capture + WinUI Composition so sidebar tiles can have rounded clip and hover-scale without HWND clipping. --- CLAUDE.md | 32 +- StageManager/Composition/CaptureSession.cs | 404 ++++++++++++++++++ StageManager/Composition/CompositionHost.cs | 130 ++++++ StageManager/Composition/CompositorFactory.cs | 65 +++ StageManager/Composition/D3DDeviceHolder.cs | 211 +++++++++ .../Composition/DispatcherQueueHelper.cs | 57 +++ .../Composition/Interop/CompositionInterop.cs | 73 ++++ .../Composition/Interop/Direct3DInterop.cs | 75 ++++ .../Interop/GraphicsCaptureItemInterop.cs | 69 +++ ...umbnail.xaml => CompositionThumbnail.xaml} | 16 +- .../Controls/CompositionThumbnail.xaml.cs | 359 ++++++++++++++++ StageManager/Controls/DwmThumbnail.xaml.cs | 164 ------- StageManager/MainWindow.xaml | 7 +- StageManager/MainWindow.xaml.cs | 35 +- .../Interop/DWM_THUMBNAIL_PROPERTIES.cs | 23 - StageManager/Native/Interop/DWM_TNP.cs | 22 - StageManager/Native/Interop/NativeMethods.cs | 13 - StageManager/Native/PInvoke/Win32.Long.cs | 1 + StageManager/StageManager.csproj | 4 +- .../Strategies/OpacityWindowStrategy.cs | 125 +++--- 20 files changed, 1567 insertions(+), 318 deletions(-) create mode 100644 StageManager/Composition/CaptureSession.cs create mode 100644 StageManager/Composition/CompositionHost.cs create mode 100644 StageManager/Composition/CompositorFactory.cs create mode 100644 StageManager/Composition/D3DDeviceHolder.cs create mode 100644 StageManager/Composition/DispatcherQueueHelper.cs create mode 100644 StageManager/Composition/Interop/CompositionInterop.cs create mode 100644 StageManager/Composition/Interop/Direct3DInterop.cs create mode 100644 StageManager/Composition/Interop/GraphicsCaptureItemInterop.cs rename StageManager/Controls/{DwmThumbnail.xaml => CompositionThumbnail.xaml} (51%) create mode 100644 StageManager/Controls/CompositionThumbnail.xaml.cs delete mode 100644 StageManager/Controls/DwmThumbnail.xaml.cs delete mode 100644 StageManager/Native/Interop/DWM_THUMBNAIL_PROPERTIES.cs delete mode 100644 StageManager/Native/Interop/DWM_TNP.cs diff --git a/CLAUDE.md b/CLAUDE.md index 99c3416..50ac4ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ No test projects exist. Verify changes by building and running manually. ## Architecture -macOS Stage Manager clone for Windows. Groups windows by process into "scenes", showing one scene at a time while hiding others via Win32 opacity tricks. +macOS Stage Manager clone for Windows. Groups windows by process into "scenes", showing one scene at a time while hiding others by parking them off-screen. ``` MainWindow.xaml.cs UI + sidebar + global mouse hooks (SharpHook) @@ -23,17 +23,37 @@ SceneManager.cs Orchestration: scene switching, window grouping, des ↓ events WindowsManager.cs Window tracking via WinEventHook, mouse hooks, focus detection ↓ -OpacityWindowStrategy.cs Hides windows by setting alpha=0 + WS_EX_TRANSPARENT (keeps DWM thumbnails live) +OpacityWindowStrategy.cs Hides windows by moving them past the virtual-screen edge so + DWM keeps compositing them (live capture frames stay valid). ``` -**Key flow**: Click sidebar scene → animation plays (SceneTransitionAnimator) → SceneManager.SwitchTo() hides other windows (alpha→0) and shows target windows (alpha→255 instant) → sidebar updates via CurrentSceneSelectionChanged event. +**Key flow**: Click sidebar scene → animation plays (SceneTransitionAnimator) → SceneManager.SwitchTo() hides other windows (off-screen park) and shows target windows (restore saved position) → sidebar updates via CurrentSceneSelectionChanged event. + +## Folder Map + +- `Animations/` — SceneTransitionAnimator, TransitionOverlayWindow, PlaceholderFactory, DragGhostWindow, DragDropManager +- `Composition/` — Windows.Graphics.Capture + WinUI Composition pipeline backing `CompositionThumbnail` (CaptureSession, CompositionHost, D3DDeviceHolder, CompositorFactory, DispatcherQueueHelper, Interop/) +- `Controls/` — CompositionThumbnail (sidebar live preview), IconOverlayManager, LayeredOverlayWindowBase +- `Model/` — `Scene` (core), `WindowModel` + `SceneModel` (INotifyPropertyChanged UI wrappers) +- `Native/` — `WindowsManager` (WinEventHook + LL mouse hook), `WindowsWindow` (`IWindow` impl), `PInvoke/` partial classes +- `Services/` — Settings, AutoStart, ThemeManager, SceneSnapshot, UpdateService, Desktop +- `Strategies/` — OpacityWindowStrategy (primary) + NormalizeAndMinimize, ShowAndHide alternates behind `IWindowStrategy` +- `Helpers/` — DesktopShellClassifier (WorkerW/Progman/SysListView32 class detection), OverlayCoordExtensions +- `Converters/` — sidebar layout converters (Index→Offset, Index→ZIndex) ## Key Design Decisions -- **OpacityWindowStrategy** over minimize: windows stay at alpha=0 so DWM can still render live thumbnails in the sidebar. The `IWindowStrategy` interface allows swapping strategies. +- **OpacityWindowStrategy** over minimize: windows are moved off-screen (past the virtual screen edge) rather than minimized, so DWM keeps compositing them and `Windows.Graphics.Capture` continues delivering live frames to the sidebar. The `IWindowStrategy` interface allows swapping strategies. The previous alpha=0 trick was abandoned because WGC captures DWM-composited (post-alpha) output and would otherwise see transparent frames. +- **Saved-position restore** (`OpacityWindowStrategy._originalPositions`): pre-hide rect captured on `Hide`, replayed on `Show`. `TryGetOriginalPosition` exposes it read-only so the scene-transition animator can target the *intended* on-screen rect of an incoming window instead of its current parked location. +- **Per-hwnd lock** (`OpacityWindowStrategy.cs`): `ConcurrentDictionary _windowLocks` serializes Show/Hide per window so concurrent calls don't race on position state. Disposed on window destroy via `CleanupWindow`. +- **Composition thumbnails** (`Controls/CompositionThumbnail`, `Composition/`): each sidebar tile owns a `CaptureSession` whose free-threaded `Direct3D11CaptureFramePool` blits into a `CompositionDrawingSurface` hosted by a per-tile `HwndHost` (`CompositionHost`). Single shared D3D11 device + WinRT projection (`D3DDeviceHolder` singleton). Sidebar pixel-alpha hit-test trick: the host containers carry `Background="#01000000"` so the layered top-level WPF window registers a non-zero alpha at thumbnail locations and `WindowFromPoint` lands on the sidebar rather than falling through to whatever is behind it. - **[Conditional("DEBUG")]** on `Log` class: all logging compiles away in Release. Log output goes to `stagemanager.log` next to the exe via `TextWriterTraceListener`. - **Scene grouping by process**: `Scene.Key` is the process filename. All windows from the same process belong to one scene. -- **Reentrancy protection**: `SceneManager.SwitchTo` has a reentrancy guard (`_reentrancyLockSceneId`) because focus events can trigger recursive switches. The animation code checks `IsCurrentScene()` before doing destructive work (hiding windows, collapsing sidebar items). +- **Reentrancy protection** (`SceneManager.cs:25`): `_suspend` bool flag set around `SwitchTo` / scene-mutation paths so focus events fired during a switch don't cascade into another switch. Event handlers early-return when `_suspend` is true. +- **Rapid-focus throttle** (`SceneManager.cs:451`): `IsRapidFocusChange()` swallows foreground events <100ms apart to block system-initiated focus loops (e.g. modal dialogs, Teams compact view). +- **Persistent windows** (`SceneManager.cs:54`): `IsPersistentWindow` excludes Teams "Meeting compact view" pop-up from scene assignment so it floats across all scenes. `GetSceneableWindows` filters these out. +- **Desktop blank-click classification** (`SceneManager.cs:202-234`, `Helpers/DesktopShellClassifier.cs`): a click on WorkerW/Progman is "blank desktop" only when the SysListView32 child reports zero selected items via `LVM_GETSELECTEDCOUNT` — distinguishes wallpaper click from icon click. Used to toggle scene ↔ desktop view. +- **MainWindow off-screen parking** (`MainWindow.xaml.cs:1052,1459`): `WindowMode.OffScreen` parks the sidebar at `Left = -Width`. DWM still composites thumbnails (Opacity=0 alone wouldn't be enough), but the window is invisible to the user. Slide-in animates Left back to 0 on hover/hotkey. ## P/Invoke Organization @@ -43,7 +63,7 @@ Win32 APIs are in `Native/PInvoke/` as partial classes on `Win32`: - `Win32.Long.cs` — Get/SetWindowLong, extended styles (WS_EX) - `Win32.WinEvent.cs` — SetWinEventHook, event constants -DWM thumbnail APIs are in `Native/Interop/NativeMethods.cs`. +Composition / DXGI bridges live in `Composition/Interop/` (CompositionInterop, Direct3DInterop, GraphicsCaptureItemInterop). ## Animation System (WIP) diff --git a/StageManager/Composition/CaptureSession.cs b/StageManager/Composition/CaptureSession.cs new file mode 100644 index 0000000..71af8c6 --- /dev/null +++ b/StageManager/Composition/CaptureSession.cs @@ -0,0 +1,404 @@ +using System; +using System.Collections.Concurrent; +using System.Numerics; +using System.Threading; +using StageManager.Composition.Interop; +using Vortice.Direct3D11; +using Vortice.DXGI; +using Windows.Graphics; +using Windows.Graphics.Capture; +using Windows.Graphics.DirectX; +using Windows.UI.Composition; +using WinRT; + +namespace StageManager.Composition +{ + /// + /// Per-HWND Windows.Graphics.Capture session that draws each captured + /// frame into a via a zero-copy + /// GPU blit. Frames arrive on a free-threaded callback; all D3D11 work + /// runs on the capture thread under . + /// + internal sealed class CaptureSession : IDisposable + { + // Per-hwnd lock — mirrors OpacityWindowStrategy's _windowLocks pattern + // so concurrent Start/Pause/Resume/Dispose for the same target HWND + // do not race on the framepool / session disposal. + private static readonly ConcurrentDictionary _hwndLocks = new(); + private static readonly Guid s_iidDxgiSurface = typeof(IDXGISurface).GUID; + + private readonly IntPtr _hwnd; + private readonly Compositor _compositor; + private readonly D3DDeviceHolder _devices; + + // Serialises ImmediateContext + drawing-surface BeginDraw/EndDraw. + // ImmediateContext is single-threaded; capture frames could in theory + // arrive concurrently if the framepool ever overlapped callbacks. + private readonly object _frameLock = new(); + + private GraphicsCaptureItem? _item; + private Direct3D11CaptureFramePool? _framePool; + private GraphicsCaptureSession? _session; + private CompositionDrawingSurface? _surface; + private ICompositionDrawingSurfaceInterop? _surfaceInterop; + // _rootContainer: HWND-sized (oversized vs. base). Transform/Opacity + // applied here so hover-scale grows the inner sprite outward but stays + // inside the HWND rectangle (which would otherwise clip the rounded + // corners of the inner sprite when scaled past base bounds). + private ContainerVisual? _rootContainer; + // _spriteVisual: base-sized, centered inside _rootContainer via Offset. + // Owns the surface brush and rounded clip. + private SpriteVisual? _spriteVisual; + private CompositionSurfaceBrush? _surfaceBrush; + private CompositionGeometricClip? _clip; + private CompositionRoundedRectangleGeometry? _clipGeometry; + private SizeInt32 _lastFrameSize; + private SizeInt32 _lastSurfaceSize; + private volatile bool _disposed; + private volatile bool _paused; + + public Visual? RootVisual => _rootContainer; + public event EventHandler? TargetClosed; + + public CaptureSession(IntPtr hwnd, Compositor compositor, D3DDeviceHolder devices) + { + _hwnd = hwnd; + _compositor = compositor; + _devices = devices; + } + + public void Start() + { + var sem = _hwndLocks.GetOrAdd(_hwnd, _ => new SemaphoreSlim(1, 1)); + sem.Wait(); + try + { + if (_disposed) return; + + _item = GraphicsCaptureItemFactory.CreateForWindow(_hwnd); + if (_item is null) + { + Log.Info("CAPSESS", $"CreateForWindow returned null for 0x{_hwnd:X}"); + return; + } + _item.Closed += OnItemClosed; + + // Seed the drawing surface at the item's known size when available + // so the first frame has a correctly-sized backbuffer. + var initialSize = (_item.Size.Width > 0 && _item.Size.Height > 0) + ? new Windows.Foundation.Size(_item.Size.Width, _item.Size.Height) + : new Windows.Foundation.Size(1, 1); + + _surface = _devices.GraphicsDevice.CreateDrawingSurface( + initialSize, + DirectXPixelFormat.B8G8R8A8UIntNormalized, + DirectXAlphaMode.Premultiplied); + + // CompositionDrawingSurface does not change identity across the + // session lifetime — cache its IUnknown projection once instead + // of QI'ing per frame. + _surfaceInterop = ((object)_surface).As(); + _lastSurfaceSize = new SizeInt32((int)initialSize.Width, (int)initialSize.Height); + + _surfaceBrush = _compositor.CreateSurfaceBrush(_surface); + _surfaceBrush.Stretch = CompositionStretch.Uniform; + _surfaceBrush.HorizontalAlignmentRatio = 0.5f; + _surfaceBrush.VerticalAlignmentRatio = 0.5f; + + _spriteVisual = _compositor.CreateSpriteVisual(); + // DesktopWindowTarget root has no implicit parent size — the + // host control drives explicit pixel size via SetVisualSize. + _spriteVisual.Size = Vector2.Zero; + _spriteVisual.Brush = _surfaceBrush; + + // Container is the externally-visible root. Hover/click scale + + // opacity ride on it; the inner sprite stays centered inside. + _rootContainer = _compositor.CreateContainerVisual(); + _rootContainer.Size = Vector2.Zero; + _rootContainer.Children.InsertAtTop(_spriteVisual); + + BuildPool(); + Log.Info("CAPSESS", $"Started capture for 0x{_hwnd:X}"); + } + finally + { + sem.Release(); + } + } + + public void SetVisualSize(Vector2 hwndPixels, Vector2 basePixels) + { + lock (_frameLock) + { + if (_disposed) return; + if (_rootContainer is not null) + _rootContainer.Size = hwndPixels; + if (_spriteVisual is not null) + { + _spriteVisual.Size = basePixels; + // Center the base-sized sprite inside the oversized container. + var off = (hwndPixels - basePixels) * 0.5f; + _spriteVisual.Offset = new Vector3(off.X, off.Y, 0f); + } + if (_clipGeometry is not null) + _clipGeometry.Size = basePixels; + } + } + + public void SetTransformMatrix(System.Numerics.Matrix4x4 transform) + { + lock (_frameLock) + { + if (_disposed || _rootContainer is null) return; + _rootContainer.TransformMatrix = transform; + } + } + + public void SetOpacity(float opacity) + { + lock (_frameLock) + { + if (_disposed || _rootContainer is null) return; + _rootContainer.Opacity = opacity; + } + } + + public void SetCornerRadius(float radiusPixels, Vector2 sizePixels) + { + lock (_frameLock) + { + if (_disposed || _spriteVisual is null) return; + + if (radiusPixels <= 0f) + { + _spriteVisual.Clip = null; + _clip?.Dispose(); _clip = null; + _clipGeometry?.Dispose(); _clipGeometry = null; + return; + } + + if (_clipGeometry is null) + { + _clipGeometry = _compositor.CreateRoundedRectangleGeometry(); + _clip = _compositor.CreateGeometricClip(); + _clip.Geometry = _clipGeometry; + _spriteVisual.Clip = _clip; + } + _clipGeometry.CornerRadius = new Vector2(radiusPixels); + _clipGeometry.Size = sizePixels; + } + } + + public void Pause() + { + if (_paused) return; + var sem = _hwndLocks.GetOrAdd(_hwnd, _ => new SemaphoreSlim(1, 1)); + sem.Wait(); + try + { + if (_disposed || _paused) return; + _paused = true; + if (_session is not null) { _session.Dispose(); _session = null; } + if (_framePool is not null) + { + _framePool.FrameArrived -= OnFrameArrived; + _framePool.Dispose(); + _framePool = null; + } + Log.Info("CAPSESS", $"Paused capture for 0x{_hwnd:X}"); + } + finally { sem.Release(); } + } + + public void Resume() + { + if (!_paused) return; + var sem = _hwndLocks.GetOrAdd(_hwnd, _ => new SemaphoreSlim(1, 1)); + sem.Wait(); + try + { + if (_disposed || _item is null) return; + BuildPool(); + _paused = false; + Log.Info("CAPSESS", $"Resumed capture for 0x{_hwnd:X}"); + } + finally { sem.Release(); } + } + + private void BuildPool() + { + if (_item is null) return; + var size = _item.Size; + if (size.Width <= 0 || size.Height <= 0) size = new SizeInt32(1, 1); + + _framePool = Direct3D11CaptureFramePool.CreateFreeThreaded( + _devices.WinRTDevice, + DirectXPixelFormat.B8G8R8A8UIntNormalized, + numberOfBuffers: 2, + size); + _framePool.FrameArrived += OnFrameArrived; + + _session = _framePool.CreateCaptureSession(_item); + // IsCursorCaptureEnabled has been on GraphicsCaptureSession since + // 19H1 (target SDK floor). IsBorderRequired needs Win11 22H2 and a + // QI to GraphicsCaptureSession2; skipped here to keep the floor. + try { _session.IsCursorCaptureEnabled = false; } catch { } + + _session.StartCapture(); + _lastFrameSize = size; + } + + private void OnFrameArrived(Direct3D11CaptureFramePool sender, object args) + { + // Free-threaded callback — runs on a capture thread pool. Must + // not touch DependencyProperties or any UI-thread state. + if (_disposed || _paused) return; + + // Hold _frameLock across the entire frame lifecycle so a concurrent + // Dispose() cannot tear down _surface / _framePool mid-blit. + lock (_frameLock) + { + if (_disposed || _paused) return; + + using var frame = sender.TryGetNextFrame(); + if (frame is null) return; + + var contentSize = frame.ContentSize; + if (contentSize.Width <= 0 || contentSize.Height <= 0) return; + + if (contentSize.Width != _lastFrameSize.Width || contentSize.Height != _lastFrameSize.Height) + { + try + { + sender.Recreate(_devices.WinRTDevice, DirectXPixelFormat.B8G8R8A8UIntNormalized, 2, contentSize); + _lastFrameSize = contentSize; + } + catch (Exception ex) + { + Log.Info("CAPSESS", $"Recreate framepool failed for 0x{_hwnd:X}: {ex.Message}"); + return; + } + } + + if (_surface is null || _surfaceInterop is null) return; + + if (contentSize.Width != _lastSurfaceSize.Width || contentSize.Height != _lastSurfaceSize.Height) + { + try + { + _surfaceInterop.Resize(new System.Drawing.Size(contentSize.Width, contentSize.Height)); + _lastSurfaceSize = contentSize; + } + catch (Exception ex) + { + Log.Info("CAPSESS", $"Surface resize failed: {ex.Message}"); + return; + } + } + + var iidDxgi = s_iidDxgiSurface; + IntPtr pDxgiSurface; + System.Drawing.Point offset; + try + { + _surfaceInterop.BeginDraw(IntPtr.Zero, ref iidDxgi, out pDxgiSurface, out offset); + } + catch (Exception ex) + { + Log.Info("CAPSESS", $"BeginDraw failed: {ex.Message}"); + return; + } + + try + { + // Vortice ComObject (IntPtr) ctor takes ownership of the + // already-AddRef'd pointer returned by BeginDraw, mirror + // of Direct3DInterop.GetTexture2DFromSurface. + using var dstSurface = new IDXGISurface(pDxgiSurface); + using var dstTex = dstSurface.QueryInterface(); + using var srcTex = Direct3DInterop.GetTexture2DFromSurface(frame.Surface); + + // Serialise immediate-context use across every CaptureSession + // in the process — the D3D11 immediate context is single-threaded + // and capture callbacks are free-threaded. + lock (D3DDeviceHolder.ContextLock) + { + _devices.ImmediateContext.CopySubresourceRegion( + dstTex, 0, + (uint)offset.X, (uint)offset.Y, 0, + srcTex, 0, + null); + } + } + catch (Exception ex) + { + Log.Info("CAPSESS", $"Frame blit failed for 0x{_hwnd:X}: {ex.Message}"); + } + finally + { + try { _surfaceInterop.EndDraw(); } + catch (Exception ex) { Log.Info("CAPSESS", $"EndDraw threw: {ex.Message}"); } + } + } + } + + private void OnItemClosed(GraphicsCaptureItem sender, object args) + { + // GraphicsCaptureItem.Closed fires on a WinRT-internal thread holding + // internal locks; calling Dispose() (which Waits on the per-hwnd + // semaphore + _frameLock) synchronously here can deadlock against a + // frame callback. Marshal off to the threadpool. + TargetClosed?.Invoke(this, EventArgs.Empty); + ThreadPool.QueueUserWorkItem(_ => Dispose()); + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + var sem = _hwndLocks.TryGetValue(_hwnd, out var s) ? s : null; + sem?.Wait(); + try + { + // Take _frameLock so an in-flight OnFrameArrived completes before + // we dispose GPU resources. Unhook _item.Closed FIRST so disposing + // the session can't synthesize a Closed callback that re-enters + // Dispose. + lock (_frameLock) + { + if (_item is not null) { _item.Closed -= OnItemClosed; } + if (_session is not null) { _session.Dispose(); _session = null; } + if (_framePool is not null) + { + _framePool.FrameArrived -= OnFrameArrived; + _framePool.Dispose(); + _framePool = null; + } + _item = null; + // Dispose container LAST among visuals — children are + // disposed by the parent container automatically, but we + // null our refs first so any racing callback short-circuits. + _spriteVisual?.Dispose(); _spriteVisual = null; + _surfaceBrush?.Dispose(); _surfaceBrush = null; + _clip?.Dispose(); _clip = null; + _clipGeometry?.Dispose(); _clipGeometry = null; + _rootContainer?.Dispose(); _rootContainer = null; + _surface?.Dispose(); _surface = null; + _surfaceInterop = null; + } + Log.Info("CAPSESS", $"Disposed capture for 0x{_hwnd:X}"); + } + finally + { + sem?.Release(); + // Drop the per-hwnd semaphore so the static dictionary doesn't + // grow unbounded across short-lived windows. + if (sem is not null && _hwndLocks.TryRemove(_hwnd, out var removed)) + { + removed.Dispose(); + } + } + } + } +} diff --git a/StageManager/Composition/CompositionHost.cs b/StageManager/Composition/CompositionHost.cs new file mode 100644 index 0000000..b4c6395 --- /dev/null +++ b/StageManager/Composition/CompositionHost.cs @@ -0,0 +1,130 @@ +using System; +using System.Runtime.InteropServices; +using System.Windows.Interop; +using StageManager.Native.PInvoke; +using Windows.UI.Composition; +using Windows.UI.Composition.Desktop; + +namespace StageManager.Composition +{ + /// + /// that creates a child HWND and attaches a + /// to it so a Composition visual tree + /// can be rendered inside a WPF panel. Consumers assign their root visual + /// to ; the setter wires it onto the underlying + /// DesktopWindowTarget.Root. + /// + internal sealed class CompositionHost : HwndHost + { + private DesktopWindowTarget? _target; + private Visual? _pendingRoot; + private bool _destroyed; + + /// + /// Shared application compositor. Same instance for every host. + /// + public Compositor Compositor => CompositorFactory.GetOrCreate(); + + /// + /// Root visual mounted on the underlying . + /// May be set before BuildWindowCore runs; in that case the + /// assignment is deferred until the target is created. + /// + public Visual? Root + { + get => _target?.Root ?? _pendingRoot; + set + { + if (_destroyed) return; + if (_target is not null) + _target.Root = value; + else + _pendingRoot = value; + } + } + + protected override HandleRef BuildWindowCore(HandleRef hwndParent) + { + // STATIC class without SS_NOTIFY returns HTTRANSPARENT, so OS mouse + // routing falls through to the WPF parent. WS_EX_NOREDIRECTIONBITMAP + // keeps DWM from allocating a redirection bitmap that conflicts with + // the AllowsTransparency=True parent. + var hwnd = Native.CreateWindowExW( + dwExStyle: (int)Win32.WS_EX.WS_EX_NOREDIRECTIONBITMAP, + lpClassName: "STATIC", + lpWindowName: string.Empty, + dwStyle: (int)(Win32.WS.WS_CHILD | Win32.WS.WS_VISIBLE), + X: 0, + Y: 0, + nWidth: 0, + nHeight: 0, + hWndParent: hwndParent.Handle, + hMenu: IntPtr.Zero, + hInstance: IntPtr.Zero, + lpParam: IntPtr.Zero); + + if (hwnd == IntPtr.Zero) + { + var err = Marshal.GetLastWin32Error(); + Log.Fatal("COMPHOST", $"CreateWindowExW failed err={err}"); + throw new InvalidOperationException($"CreateWindowExW failed (Win32 {err})"); + } + + _target = CompositorFactory.CreateTargetForHwnd(hwnd, isTopmost: false); + + if (_pendingRoot is not null) + { + _target.Root = _pendingRoot; + _pendingRoot = null; + } + + Log.Info("COMPHOST", "Created composition child", hwnd); + return new HandleRef(this, hwnd); + } + + protected override void DestroyWindowCore(HandleRef hwnd) + { + _destroyed = true; + _pendingRoot = null; + + try + { + _target?.Dispose(); + } + catch (Exception ex) + { + Log.Info("COMPHOST", $"Target dispose threw: {ex.Message}"); + } + finally + { + _target = null; + } + + if (hwnd.Handle != IntPtr.Zero) + Native.DestroyWindow(hwnd.Handle); + } + + // Local P/Invokes — only used here by HwndHost child-window construction. + private static class Native + { + [DllImport("user32.dll", EntryPoint = "CreateWindowExW", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern IntPtr CreateWindowExW( + int dwExStyle, + string lpClassName, + string lpWindowName, + int dwStyle, + int X, + int Y, + int nWidth, + int nHeight, + IntPtr hWndParent, + IntPtr hMenu, + IntPtr hInstance, + IntPtr lpParam); + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool DestroyWindow(IntPtr hWnd); + } + } +} diff --git a/StageManager/Composition/CompositorFactory.cs b/StageManager/Composition/CompositorFactory.cs new file mode 100644 index 0000000..c5de586 --- /dev/null +++ b/StageManager/Composition/CompositorFactory.cs @@ -0,0 +1,65 @@ +using System; +using System.Runtime.InteropServices; +using Windows.UI.Composition; +using Windows.UI.Composition.Desktop; +using WinRT; + +namespace StageManager.Composition +{ + /// + /// Single shared for the application and helper to + /// build a for a given HWND via + /// ICompositorDesktopInterop. + /// + internal static class CompositorFactory + { + // Returns the IInspectable as a raw IntPtr instead of letting the runtime + // marshal it. CsWinRT 2.x projected types (DesktopWindowTarget) cannot be + // produced by the built-in COM marshaler — it hands back a generic + // __ComObject and the cast to the projection type fails. We must lift + // the ABI pointer through MarshalInspectable.FromAbi instead. + [ComImport] + [Guid("29E691FA-4567-4DCA-B319-D0F207EB6807")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + internal interface ICompositorDesktopInterop + { + void CreateDesktopWindowTarget( + IntPtr hwndTarget, + [MarshalAs(UnmanagedType.Bool)] bool isTopmost, + out IntPtr target); + } + + private static Compositor? _instance; + + /// + /// Returns the process-wide , creating it on + /// first use. Ensures a dispatcher queue exists on the current thread + /// before the compositor is constructed. + /// + public static Compositor GetOrCreate() + { + DispatcherQueueHelper.EnsureOnCurrentThread(); + return _instance ??= new Compositor(); + } + + /// + /// Creates a bound to the given HWND. + /// The caller owns the returned target and must keep it alive (it + /// disconnects its visual tree when GC'd). + /// + public static DesktopWindowTarget CreateTargetForHwnd(IntPtr hwnd, bool isTopmost) + { + var compositor = GetOrCreate(); + var interop = compositor.As(); + interop.CreateDesktopWindowTarget(hwnd, isTopmost, out var abi); + try + { + return MarshalInspectable.FromAbi(abi); + } + finally + { + MarshalInspectable.DisposeAbi(abi); + } + } + } +} diff --git a/StageManager/Composition/D3DDeviceHolder.cs b/StageManager/Composition/D3DDeviceHolder.cs new file mode 100644 index 0000000..92b088c --- /dev/null +++ b/StageManager/Composition/D3DDeviceHolder.cs @@ -0,0 +1,211 @@ +using System; +using System.Runtime.InteropServices; +using StageManager.Composition.Interop; +using Vortice.Direct3D; +using Vortice.Direct3D11; +using Vortice.DXGI; +using Windows.Foundation; +using Windows.UI.Composition; +using WinRT; +using WinRTDirect3D11 = Windows.Graphics.DirectX.Direct3D11; + +namespace StageManager.Composition +{ + /// + /// Process-wide owner of the shared D3D11 device used by every capture + /// session and composition surface. Wraps: + /// * a Vortice + immediate context + /// * the same device projected as WinRT + /// for + /// Direct3D11CaptureFramePool + /// * a + /// bound to the compositor for drawing surfaces + /// Subscribes to RenderingDeviceReplaced so DWM-induced device loss + /// triggers an automatic rebuild; live capture sessions react via the + /// public event. + /// + internal sealed class D3DDeviceHolder : IDisposable + { + private static readonly object _lock = new(); + private static D3DDeviceHolder? _instance; + + /// + /// Process-wide gate for ID3D11DeviceContext use. The immediate context is + /// single-threaded; every BeginDraw/CopySubresourceRegion/EndDraw chain + /// across all CaptureSessions must hold this lock. + /// + public static readonly object ContextLock = new(); + + /// + /// Returns the singleton, lazily constructing it on first call. The + /// supplied compositor is captured for the lifetime of the process — + /// subsequent calls ignore the argument. + /// + public static D3DDeviceHolder GetOrCreate(Compositor compositor) + { + if (compositor is null) + throw new ArgumentNullException(nameof(compositor)); + + lock (_lock) + { + return _instance ??= new D3DDeviceHolder(compositor); + } + } + + public ID3D11Device D3DDevice { get; private set; } = null!; + public ID3D11DeviceContext ImmediateContext { get; private set; } = null!; + public WinRTDirect3D11.IDirect3DDevice WinRTDevice { get; private set; } = null!; + public CompositionGraphicsDevice GraphicsDevice { get; private set; } = null!; + + /// + /// Raised after the underlying D3D11 device has been recreated + /// following device loss. Listeners (capture sessions, surfaces) + /// should drop and rebuild any GPU resources they held. + /// + public event EventHandler? DeviceLost; + + private readonly Compositor _compositor; + private TypedEventHandler? _renderingReplacedHandler; + private bool _disposed; + + private D3DDeviceHolder(Compositor compositor) + { + _compositor = compositor; + Build(); + } + + private void Build() + { + // 1) Try a hardware device with BGRA support (required for + // composition interop). Fall back to WARP (still GPU-pipelined + // on the software rasteriser) if hardware creation fails. + var flags = DeviceCreationFlags.BgraSupport; + var featureLevels = new[] + { + FeatureLevel.Level_11_1, + FeatureLevel.Level_11_0, + FeatureLevel.Level_10_1, + FeatureLevel.Level_10_0, + }; + + ID3D11Device device; + ID3D11DeviceContext context; + + try + { + D3D11.D3D11CreateDevice( + adapter: null, + DriverType.Hardware, + flags, + featureLevels, + out device, + out context).CheckError(); + Log.Info("D3DDEV", "Created hardware D3D11 device"); + } + catch (Exception hwEx) + { + Log.Info("D3DDEV", $"Hardware D3D11 device creation failed: {hwEx.Message} — falling back to WARP"); + D3D11.D3D11CreateDevice( + adapter: null, + DriverType.Warp, + flags, + featureLevels, + out device, + out context).CheckError(); + Log.Info("D3DDEV", "Created WARP D3D11 device"); + } + + D3DDevice = device; + ImmediateContext = context; + + // 2) Same D3D11 device, viewed as IDXGIDevice — needed by + // CreateDirect3D11DeviceFromDXGIDevice. + using var dxgiDevice = D3DDevice.QueryInterface(); + + // 3) WinRT projection of the device for capture/composition. + WinRTDevice = Direct3DInterop.CreateDirect3DDevice(dxgiDevice); + + // 4) CompositionGraphicsDevice — feeds CreateDrawingSurface and + // fires RenderingDeviceReplaced on DWM device loss. + // ICompositorInterop::CreateGraphicsDevice wants a raw D3D/D2D + // device IUnknown — NOT the WinRT IDirect3DDevice projection. + // The result is an IInspectable* we unwrap manually because + // CsWinRT 2.x's projected-type marshal is broken in COM ABI + // signatures (see CompositionInterop.cs). + var interop = _compositor.As(); + interop.CreateGraphicsDevice(D3DDevice.NativePointer, out IntPtr gdAbi); + try + { + GraphicsDevice = MarshalInspectable.FromAbi(gdAbi); + } + finally + { + MarshalInspectable.DisposeAbi(gdAbi); + } + + _renderingReplacedHandler = OnRenderingDeviceReplaced; + GraphicsDevice.RenderingDeviceReplaced += _renderingReplacedHandler; + } + + private void OnRenderingDeviceReplaced(CompositionGraphicsDevice sender, RenderingDeviceReplacedEventArgs args) + { + if (_disposed) return; + Log.Info("D3DDEV", "RenderingDeviceReplaced fired — rebuilding D3D11 device"); + TeardownDevice(); + Build(); + DeviceLost?.Invoke(this, EventArgs.Empty); + } + + private void TeardownDevice() + { + if (_renderingReplacedHandler is not null && GraphicsDevice is not null) + { + try { GraphicsDevice.RenderingDeviceReplaced -= _renderingReplacedHandler; } + catch (Exception ex) { Log.Info("D3DDEV", $"Unsubscribe RenderingDeviceReplaced threw: {ex.Message}"); } + } + _renderingReplacedHandler = null; + + // Release in reverse order of creation; swallow individual + // failures so a single bad release never blocks the rest. + SafeDispose(GraphicsDevice, "GraphicsDevice"); + SafeDispose(WinRTDevice, "WinRTDevice"); + SafeDispose(ImmediateContext, "ImmediateContext"); + SafeDispose(D3DDevice, "D3DDevice"); + + GraphicsDevice = null!; + WinRTDevice = null!; + ImmediateContext = null!; + D3DDevice = null!; + } + + private static void SafeDispose(object? obj, string tag) + { + if (obj is null) + return; + try + { + if (obj is IDisposable disposable) + disposable.Dispose(); + } + catch (Exception ex) + { + Log.Info("D3DDEV", $"Dispose of {tag} threw: {ex.Message}"); + } + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + + TeardownDevice(); + + lock (_lock) + { + if (ReferenceEquals(_instance, this)) + _instance = null; + } + } + } +} diff --git a/StageManager/Composition/DispatcherQueueHelper.cs b/StageManager/Composition/DispatcherQueueHelper.cs new file mode 100644 index 0000000..aed1521 --- /dev/null +++ b/StageManager/Composition/DispatcherQueueHelper.cs @@ -0,0 +1,57 @@ +using System; +using System.Runtime.InteropServices; + +namespace StageManager.Composition +{ + /// + /// Ensures a Windows.System.DispatcherQueueController exists on the + /// current thread. Required so a Windows.UI.Composition.Compositor + /// can run on the UI thread (the compositor needs a dispatcher queue to + /// schedule animation callbacks). + /// + internal static class DispatcherQueueHelper + { + // DQTYPE_THREAD_CURRENT + private const int DQTYPE_THREAD_CURRENT = 2; + // DQTAT_COM_STA + private const int DQTAT_COM_STA = 2; + + [StructLayout(LayoutKind.Sequential)] + private struct DispatcherQueueOptions + { + public int dwSize; + public int threadType; + public int apartmentType; + } + + [DllImport("coremessaging.dll", ExactSpelling = true, CharSet = CharSet.Unicode, PreserveSig = false)] + private static extern void CreateDispatcherQueueController( + DispatcherQueueOptions options, + [MarshalAs(UnmanagedType.IUnknown)] out object dispatcherQueueController); + + // One controller per thread. Holding the reference keeps the queue alive + // for the lifetime of the thread. + [ThreadStatic] + private static object? _controller; + + /// + /// Creates a dispatcher queue controller on the current thread if one + /// does not already exist. Idempotent. + /// + public static void EnsureOnCurrentThread() + { + if (_controller is not null) + return; + + var options = new DispatcherQueueOptions + { + dwSize = Marshal.SizeOf(), + threadType = DQTYPE_THREAD_CURRENT, + apartmentType = DQTAT_COM_STA, + }; + + CreateDispatcherQueueController(options, out var controller); + _controller = controller; + } + } +} diff --git a/StageManager/Composition/Interop/CompositionInterop.cs b/StageManager/Composition/Interop/CompositionInterop.cs new file mode 100644 index 0000000..3420fa5 --- /dev/null +++ b/StageManager/Composition/Interop/CompositionInterop.cs @@ -0,0 +1,73 @@ +using System; +using System.Runtime.InteropServices; +using Windows.UI.Composition; + +namespace StageManager.Composition.Interop +{ + /// + /// Hand-declared COM interop for Windows.UI.Composition bridges that + /// are not provided by the CsWinRT projection. These are the well-known + /// ABI interfaces exposed by Compositor and + /// CompositionDrawingSurface; we cast (QI) the projected WinRT + /// objects to them with compositor.As<T>(). + /// + // All parameters are raw IntPtr (IInspectable*) — see the note in + // CompositorFactory.cs. Letting the CLR marshal a WinRT projected type + // (CompositionGraphicsDevice / ICompositionSurface) directly through a + // COM ABI signature breaks under CsWinRT 2.x because the runtime + // produces a __ComObject and fails to cast it to the projection. + // We unwrap manually with MarshalInspectable.FromAbi at the call site. + [ComImport] + [Guid("25297D5C-3AD4-4C9C-B5CF-E36A38512330")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + internal interface ICompositorInterop + { + void CreateCompositionSurfaceForHandle( + IntPtr swapChain, + out IntPtr result); + + void CreateCompositionSurfaceForSwapChain( + IntPtr swapChain, + out IntPtr result); + + void CreateGraphicsDevice( + IntPtr renderingDevice, + out IntPtr result); + } + + /// + /// ABI interface exposed by every CompositionDrawingSurface; gives + /// access to the underlying texture for drawing without round-tripping + /// through CPU memory. + /// + [ComImport] + [Guid("FD04E6E3-FE0C-4C3C-AB19-A07601A576EE")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + internal interface ICompositionDrawingSurfaceInterop + { + void BeginDraw( + IntPtr updateRect, + [In] ref Guid iid, + out IntPtr updateObject, + out System.Drawing.Point updateOffset); + + void EndDraw(); + + void Resize(System.Drawing.Size sizePixels); + } + + /// + /// Bridge from a WinRT IDirect3DSurface (returned by + /// Direct3D11CaptureFrame.Surface) to the underlying D3D11 / DXGI + /// COM object. Pass the GUID of ID3D11Texture2D or + /// IDXGISurface; receive a raw IUnknown* the caller must + /// release. + /// + [ComImport] + [Guid("A9B3D012-3DF2-4EE3-B8D1-8695F457D3C1")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + internal interface IDirect3DDxgiInterfaceAccess + { + IntPtr GetInterface([In] ref Guid iid); + } +} diff --git a/StageManager/Composition/Interop/Direct3DInterop.cs b/StageManager/Composition/Interop/Direct3DInterop.cs new file mode 100644 index 0000000..0fd597d --- /dev/null +++ b/StageManager/Composition/Interop/Direct3DInterop.cs @@ -0,0 +1,75 @@ +using System; +using System.Runtime.InteropServices; +using Vortice.Direct3D11; +using Vortice.DXGI; +using WinRT; +using WinRTDirect3D11 = Windows.Graphics.DirectX.Direct3D11; + +namespace StageManager.Composition.Interop +{ + /// + /// Bridges Vortice's D3D11/DXGI COM wrappers to the WinRT + /// IDirect3DDevice / IDirect3DSurface projections required + /// by Direct3D11CaptureFramePool. All transfers stay GPU-side; no + /// CPU readback occurs. + /// + internal static class Direct3DInterop + { + private static readonly Guid s_iidTexture2D = typeof(ID3D11Texture2D).GUID; + + // HRESULT CreateDirect3D11DeviceFromDXGIDevice(IDXGIDevice*, IInspectable**) + // PreserveSig = false → throws on non-zero HRESULT. + [DllImport("d3d11.dll", PreserveSig = false)] + private static extern void CreateDirect3D11DeviceFromDXGIDevice( + IntPtr dxgiDevice, + out IntPtr graphicsDevice); + + /// + /// Wraps a Vortice as the WinRT + /// consumed by + /// Direct3D11CaptureFramePool.Create. + /// + public static WinRTDirect3D11.IDirect3DDevice CreateDirect3DDevice(IDXGIDevice dxgiDevice) + { + if (dxgiDevice is null) + throw new ArgumentNullException(nameof(dxgiDevice)); + + CreateDirect3D11DeviceFromDXGIDevice(dxgiDevice.NativePointer, out IntPtr inspectable); + try + { + // FromAbi wraps without consuming a ref; DisposeAbi (Release) below + // balances the AddRef that CreateDirect3D11DeviceFromDXGIDevice gave us. + return MarshalInspectable.FromAbi(inspectable); + } + finally + { + MarshalInspectable.DisposeAbi(inspectable); + } + } + + /// + /// Extracts the underlying from a + /// captured WinRT . The + /// returned texture wraps the same GPU resource the capture session + /// produced. + /// + public static ID3D11Texture2D GetTexture2DFromSurface(WinRTDirect3D11.IDirect3DSurface surface) + { + if (surface is null) + throw new ArgumentNullException(nameof(surface)); + + // CsWinRT 2.x's projection-aware path will not produce a + // [ComImport] interface via direct cast — it reports an invalid + // cast on the IInspectable RCW. Go through CastExtensions.As<>(), + // which performs an explicit IUnknown QI for the [Guid] attribute. + var access = surface.As(); + var iid = s_iidTexture2D; + // GetInterface returns an AddRef'd IUnknown* (QI semantics). + // Vortice's (IntPtr) ctor wraps without taking an extra ref, so + // the AddRef from GetInterface becomes the wrapper's owning ref — + // it is released when the caller disposes the texture. + IntPtr pTex = access.GetInterface(ref iid); + return new ID3D11Texture2D(pTex); + } + } +} diff --git a/StageManager/Composition/Interop/GraphicsCaptureItemInterop.cs b/StageManager/Composition/Interop/GraphicsCaptureItemInterop.cs new file mode 100644 index 0000000..2fc157e --- /dev/null +++ b/StageManager/Composition/Interop/GraphicsCaptureItemInterop.cs @@ -0,0 +1,69 @@ +using System; +using System.Runtime.InteropServices; +using Windows.Graphics.Capture; +using WinRT; + +namespace StageManager.Composition.Interop +{ + /// + /// ABI factory bridge for : the WinRT + /// projection has no public constructor, so we must reach the activation + /// factory's IGraphicsCaptureItemInterop ABI to build an item from + /// an HWND or HMONITOR. + /// + [ComImport] + [Guid("3628E81B-3CAC-4C60-B7F4-23CE0E0C3356")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + internal interface IGraphicsCaptureItemInterop + { + IntPtr CreateForWindow([In] IntPtr window, [In] ref Guid iid); + IntPtr CreateForMonitor([In] IntPtr monitor, [In] ref Guid iid); + } + + internal static class GraphicsCaptureItemFactory + { + // IID for Windows.Graphics.Capture.IGraphicsCaptureItem. + // Hardcoded to avoid relying on typeof(GraphicsCaptureItem).GUID + // (which on some CsWinRT versions returns the helper-type GUID). + private static Guid s_captureItemIid = new("79C3F95B-31F7-4EC2-A464-632EF5D30760"); + + public static GraphicsCaptureItem? CreateForWindow(IntPtr hwnd) + { + if (hwnd == IntPtr.Zero) return null; + + try + { + // CsWinRT 2.x: ActivationFactory.Get returns IObjectReference + // whose ThisPtr is the IUnknown of the factory. Marshal it + // into an RCW so we can QI for our [ComImport] ABI interface. + var factory = ActivationFactory.Get("Windows.Graphics.Capture.GraphicsCaptureItem"); + var rcw = Marshal.GetObjectForIUnknown(factory.ThisPtr); + try + { + var interop = (IGraphicsCaptureItemInterop)rcw; + var iid = s_captureItemIid; + var ptr = interop.CreateForWindow(hwnd, ref iid); + if (ptr == IntPtr.Zero) return null; + + try + { + return MarshalInspectable.FromAbi(ptr); + } + finally + { + Marshal.Release(ptr); + } + } + finally + { + Marshal.ReleaseComObject(rcw); + } + } + catch (Exception ex) + { + Log.Info("WGCITEM", $"CreateForWindow failed for 0x{hwnd:X}: {ex.Message}"); + return null; + } + } + } +} diff --git a/StageManager/Controls/DwmThumbnail.xaml b/StageManager/Controls/CompositionThumbnail.xaml similarity index 51% rename from StageManager/Controls/DwmThumbnail.xaml rename to StageManager/Controls/CompositionThumbnail.xaml index a79d85d..c33ca7c 100644 --- a/StageManager/Controls/DwmThumbnail.xaml +++ b/StageManager/Controls/CompositionThumbnail.xaml @@ -1,5 +1,5 @@ - - + - + + diff --git a/StageManager/Controls/CompositionThumbnail.xaml.cs b/StageManager/Controls/CompositionThumbnail.xaml.cs new file mode 100644 index 0000000..d809179 --- /dev/null +++ b/StageManager/Controls/CompositionThumbnail.xaml.cs @@ -0,0 +1,359 @@ +using System; +using System.Numerics; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using StageManager.Composition; + +namespace StageManager.Controls +{ + /// + /// Window thumbnail control backed by + /// Windows.Graphics.Capture + Windows.UI.Composition. Public surface + /// is identical: a single dependency + /// property; layout (Width/Height/Margin) is honoured by WPF. + /// + public partial class CompositionThumbnail : UserControl + { + private CompositionHost? _compositionHost; + private D3DDeviceHolder? _devices; + private CaptureSession? _session; + private EventHandler? _deviceLostHandler; + // Base = the visible "at rest" rect (matches WPF Width/Height). + // Hwnd = the oversized HWND that gives MirrorScale headroom so the + // rounded clip on the inner SpriteVisual never escapes the HWND rect. + private double _lastBasePixelWidth; + private double _lastBasePixelHeight; + private double _lastHwndPixelWidth; + private double _lastHwndPixelHeight; + private double _lastAppliedRadiusPx = double.NaN; + private Matrix4x4 _lastAppliedTransform = Matrix4x4.Identity; + private float _lastAppliedOpacity = 1f; + + // Each side. Total HWND inflation = 1 + 2 * HoverHeadroom. + // 6% per side accommodates MirrorScale up to ~1.12 — current animation + // peaks at 1.08 (hover) / 1.08 (click bounce up) with margin to spare. + private const double HoverHeadroom = 0.06; + + public CompositionThumbnail() + { + InitializeComponent(); + Loaded += OnLoaded; + Unloaded += OnUnloaded; + IsVisibleChanged += OnIsVisibleChanged; + SizeChanged += OnSizeChanged; + } + + public static readonly DependencyProperty PreviewHandleProperty = DependencyProperty.Register( + nameof(PreviewHandle), + typeof(IntPtr), + typeof(CompositionThumbnail), + new PropertyMetadata(IntPtr.Zero)); + + public IntPtr PreviewHandle + { + get { return (IntPtr)GetValue(PreviewHandleProperty); } + set { SetValue(PreviewHandleProperty, value); } + } + + public static readonly DependencyProperty CornerRadiusProperty = DependencyProperty.Register( + nameof(CornerRadius), + typeof(double), + typeof(CompositionThumbnail), + new PropertyMetadata(0.0, OnCornerRadiusChanged)); + + public double CornerRadius + { + get => (double)GetValue(CornerRadiusProperty); + set => SetValue(CornerRadiusProperty, value); + } + + private static void OnCornerRadiusChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is CompositionThumbnail ct) + ct.ApplyCornerRadius(); + } + + public static readonly DependencyProperty SkewAngleDegreesProperty = DependencyProperty.Register( + nameof(SkewAngleDegrees), + typeof(double), + typeof(CompositionThumbnail), + new PropertyMetadata(0.0, OnTransformInputChanged)); + + public double SkewAngleDegrees + { + get => (double)GetValue(SkewAngleDegreesProperty); + set => SetValue(SkewAngleDegreesProperty, value); + } + + // Mirrors the WPF ancestor's animated ScaleX/Y onto the SpriteVisual + // because HwndHost child windows ignore WPF RenderTransform. + public static readonly DependencyProperty MirrorScaleProperty = DependencyProperty.Register( + nameof(MirrorScale), + typeof(double), + typeof(CompositionThumbnail), + new PropertyMetadata(1.0, OnTransformInputChanged)); + + public double MirrorScale + { + get => (double)GetValue(MirrorScaleProperty); + set => SetValue(MirrorScaleProperty, value); + } + + // Mirrors the WPF ancestor's animated Opacity onto the SpriteVisual. + public static readonly DependencyProperty MirrorOpacityProperty = DependencyProperty.Register( + nameof(MirrorOpacity), + typeof(double), + typeof(CompositionThumbnail), + new PropertyMetadata(1.0, OnMirrorOpacityChanged)); + + public double MirrorOpacity + { + get => (double)GetValue(MirrorOpacityProperty); + set => SetValue(MirrorOpacityProperty, value); + } + + private static void OnTransformInputChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is CompositionThumbnail ct) + ct.ApplyTransform(); + } + + private static void OnMirrorOpacityChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is CompositionThumbnail ct) + ct.ApplyOpacity(); + } + + protected override void OnPropertyChanged(DependencyPropertyChangedEventArgs e) + { + base.OnPropertyChanged(e); + + if (e.Property == PreviewHandleProperty) + { + if ((IntPtr)e.OldValue == IntPtr.Zero && (IntPtr)e.NewValue != IntPtr.Zero) + StartCaptureIfReady(); + else if ((IntPtr)e.NewValue == IntPtr.Zero && _session is not null) + TeardownSession(); + } + } + + // IsVisible isn't a regular DP — changes don't always route through + // OnPropertyChanged. Subscribe to the dedicated event. + private void OnIsVisibleChanged(object sender, DependencyPropertyChangedEventArgs e) + { + var nowVisible = (bool)e.NewValue; + if (!nowVisible) + _session?.Pause(); + else if (_session is not null) + _session.Resume(); + else if (PreviewHandle != IntPtr.Zero) + StartCaptureIfReady(); + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + if (_compositionHost is not null) return; + + _compositionHost = new CompositionHost(); + HostContainer.Children.Add(_compositionHost); + + var compositor = _compositionHost.Compositor; + _devices = D3DDeviceHolder.GetOrCreate(compositor); + _deviceLostHandler = OnDeviceLost; + _devices.DeviceLost += _deviceLostHandler; + + if (PreviewHandle != IntPtr.Zero && IsVisible) + StartCaptureIfReady(); + } + + private void OnUnloaded(object sender, RoutedEventArgs e) + { + TeardownSession(); + + if (_devices is not null && _deviceLostHandler is not null) + _devices.DeviceLost -= _deviceLostHandler; + _deviceLostHandler = null; + _devices = null; + + if (_compositionHost is not null) + { + try { HostContainer.Children.Remove(_compositionHost); } + catch (Exception ex) { Log.Info("COMPTHUMB", $"HostContainer.Remove threw: {ex.Message}"); } + try { _compositionHost.Dispose(); } + catch (Exception ex) { Log.Info("COMPTHUMB", $"CompositionHost.Dispose threw: {ex.Message}"); } + _compositionHost = null; + } + } + + private void StartCaptureIfReady() + { + if (_compositionHost is null || _devices is null) return; + if (_session is not null) return; + if (PreviewHandle == IntPtr.Zero) return; + + _session = new CaptureSession(PreviewHandle, _compositionHost.Compositor, _devices); + _session.TargetClosed += OnTargetClosed; + _session.Start(); + _compositionHost.Root = _session.RootVisual; + + RecomputePixelSize(); + ApplyCornerRadius(); + ApplyTransform(); + ApplyOpacity(); + + Log.Info("COMPTHUMB", $"Started session for 0x{PreviewHandle:X}"); + } + + private void OnSizeChanged(object sender, SizeChangedEventArgs e) + { + RecomputePixelSize(); + ApplyCornerRadius(); + ApplyTransform(); + } + + protected override void OnDpiChanged(DpiScale oldDpi, DpiScale newDpi) + { + base.OnDpiChanged(oldDpi, newDpi); + RecomputePixelSize(); + ApplyCornerRadius(); + ApplyTransform(); + } + + private void RecomputePixelSize() + { + if (_session is null) return; + var dpi = VisualTreeHelper.GetDpi(this); + var baseW = ActualWidth * dpi.DpiScaleX; + var baseH = ActualHeight * dpi.DpiScaleY; + var hwndW = baseW * (1.0 + 2.0 * HoverHeadroom); + var hwndH = baseH * (1.0 + 2.0 * HoverHeadroom); + + // Drive HostContainer's WPF layout size so the underlying HwndHost + // reserves the oversized HWND. HorizontalAlignment=Center + + // explicit Width/Height in XAML cause WPF to position it centered + // inside the UserControl, overflowing equally on each side. + HostContainer.Width = ActualWidth * (1.0 + 2.0 * HoverHeadroom); + HostContainer.Height = ActualHeight * (1.0 + 2.0 * HoverHeadroom); + + if (baseW == _lastBasePixelWidth && baseH == _lastBasePixelHeight && + hwndW == _lastHwndPixelWidth && hwndH == _lastHwndPixelHeight) return; + + _lastBasePixelWidth = baseW; + _lastBasePixelHeight = baseH; + _lastHwndPixelWidth = hwndW; + _lastHwndPixelHeight = hwndH; + // Size changed → cached radius / transform are tied to the old size. + _lastAppliedRadiusPx = double.NaN; + _lastAppliedTransform = new Matrix4x4(float.NaN, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + _session.SetVisualSize( + new Vector2((float)hwndW, (float)hwndH), + new Vector2((float)baseW, (float)baseH)); + } + + private void ApplyCornerRadius() + { + if (_session is null) return; + var dpi = VisualTreeHelper.GetDpi(this); + var pixelRadius = CornerRadius * dpi.DpiScaleX; + if (pixelRadius == _lastAppliedRadiusPx) return; + _lastAppliedRadiusPx = pixelRadius; + // Clip lives on the inner sprite (base pixel size), not the + // oversized HWND-sized container. + _session.SetCornerRadius( + (float)pixelRadius, + new Vector2((float)_lastBasePixelWidth, (float)_lastBasePixelHeight)); + } + + // Combined skew + scale transform around the visual center. Mirrors the + // WPF ancestor's animated ScaleTransform — HwndHost children ignore + // WPF RenderTransform, so the SpriteVisual.TransformMatrix has to do it. + private void ApplyTransform() + { + if (_session is null) return; + // Transform applied to the HWND-sized container — center is HWND/2, + // which coincides with the inner sprite's center (sprite is + // centered inside the container). + var matrix = ComposeTransform(SkewAngleDegrees, MirrorScale, _lastHwndPixelWidth, _lastHwndPixelHeight); + if (matrix == _lastAppliedTransform) return; + _lastAppliedTransform = matrix; + _session.SetTransformMatrix(matrix); + } + + private void ApplyOpacity() + { + if (_session is null) return; + var op = (float)Math.Clamp(MirrorOpacity, 0.0, 1.0); + if (op == _lastAppliedOpacity) return; + _lastAppliedOpacity = op; + _session.SetOpacity(op); + } + + private static Matrix4x4 ComposeTransform(double angleDegrees, double scale, double pixelWidth, double pixelHeight) + { + var s = (float)scale; + if (angleDegrees == 0.0 && s == 1f) + return Matrix4x4.Identity; + + var cx = (float)(pixelWidth / 2.0); + var cy = (float)(pixelHeight / 2.0); + + var inner = Matrix4x4.CreateScale(s, s, 1f); + if (angleDegrees != 0.0) + { + var rad = (float)(angleDegrees * Math.PI / 180.0); + var skew = Matrix4x4.Identity; + skew.M21 = (float)Math.Tan(rad); + inner = inner * skew; + } + + var t1 = Matrix4x4.CreateTranslation(-cx, -cy, 0); + var t2 = Matrix4x4.CreateTranslation(cx, cy, 0); + return t1 * inner * t2; + } + + private void TeardownSession() + { + if (_session is null) return; + _session.TargetClosed -= OnTargetClosed; + + if (_compositionHost is not null) + _compositionHost.Root = null; + + try { _session.Dispose(); } + catch (Exception ex) { Log.Info("COMPTHUMB", $"Session.Dispose threw: {ex.Message}"); } + _session = null; + _lastBasePixelWidth = 0; + _lastBasePixelHeight = 0; + _lastHwndPixelWidth = 0; + _lastHwndPixelHeight = 0; + _lastAppliedRadiusPx = double.NaN; + _lastAppliedTransform = new Matrix4x4(float.NaN, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); + _lastAppliedOpacity = float.NaN; + } + + private void OnTargetClosed(object? sender, EventArgs e) + { + // TargetClosed fires off-thread; marshal teardown to UI to keep + // CompositionHost mutations on the dispatcher that owns it. + Dispatcher.BeginInvoke(new Action(() => + { + if (_compositionHost is not null) + _compositionHost.Root = null; + TeardownSession(); + })); + } + + private void OnDeviceLost(object? sender, EventArgs e) + { + Dispatcher.BeginInvoke(new Action(() => + { + if (PreviewHandle == IntPtr.Zero) return; + Log.Info("COMPTHUMB", $"Device lost — restarting session for 0x{PreviewHandle:X}"); + TeardownSession(); + if (IsVisible) + StartCaptureIfReady(); + })); + } + } +} diff --git a/StageManager/Controls/DwmThumbnail.xaml.cs b/StageManager/Controls/DwmThumbnail.xaml.cs deleted file mode 100644 index fbcd01f..0000000 --- a/StageManager/Controls/DwmThumbnail.xaml.cs +++ /dev/null @@ -1,164 +0,0 @@ -using StageManager.Native.Interop; -using StageManager.Native.PInvoke; -using System; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Media; - -namespace StageManager.Controls -{ - /// - /// Interaction logic for DwmThumbnail.xaml - /// - public partial class DwmThumbnail : UserControl - { - public DwmThumbnail() - { - InitializeComponent(); - LayoutUpdated += DwmThumbnail_LayoutUpdated; - Loaded += (_, _) => CompositionTarget.Rendering += OnRenderingTick; - Unloaded += (_, _) => CompositionTarget.Rendering -= OnRenderingTick; - } - - private IntPtr _dwmThumbnail; - private Window _window; - private Point? _dpiScaleFactor; - private Win32.Rect _lastRect; - private bool _hasLastRect; - - public static readonly DependencyProperty PreviewHandleProperty = DependencyProperty.Register(nameof(PreviewHandle), - typeof(IntPtr), - typeof(DwmThumbnail), - new PropertyMetadata(IntPtr.Zero)); - - public IntPtr PreviewHandle - { - get { return (IntPtr)GetValue(PreviewHandleProperty); } - set { SetValue(PreviewHandleProperty, value); } - } - - private Point GetDpiScaleFactor() - { - if (_dpiScaleFactor is null) - { - var source = PresentationSource.FromVisual(this); - _dpiScaleFactor = source?.CompositionTarget != null ? new Point(source.CompositionTarget.TransformToDevice.M11, source.CompositionTarget.TransformToDevice.M22) : new Point(1.0d, 1.0d); - } - - return _dpiScaleFactor.Value; - } - - protected override void OnDpiChanged(DpiScale oldDpi, DpiScale newDpi) - { - _dpiScaleFactor = null; - base.OnDpiChanged(oldDpi, newDpi); - } - - protected override void OnPropertyChanged(DependencyPropertyChangedEventArgs e) - { - base.OnPropertyChanged(e); - - if (nameof(PreviewHandle).Equals(e.Property.Name)) - { - if ((IntPtr)e.OldValue == IntPtr.Zero && (IntPtr)e.NewValue != IntPtr.Zero) - StartCapture(); - - UpdateThumbnailProperties(); - } - - if (nameof(IsVisible).Equals(e.Property.Name)) - { - var nowVisible = (bool)e.NewValue; - if (!nowVisible && _dwmThumbnail != IntPtr.Zero) - { - NativeMethods.DwmUnregisterThumbnail(_dwmThumbnail); - _dwmThumbnail = IntPtr.Zero; - _hasLastRect = false; - } - else if (nowVisible && _dwmThumbnail == IntPtr.Zero && PreviewHandle != IntPtr.Zero) - { - StartCapture(); - UpdateThumbnailProperties(); - } - } - } - - private void DwmThumbnail_LayoutUpdated(object? sender, EventArgs e) - { - UpdateThumbnailProperties(); - } - - public static Rect BoundsRelativeTo(FrameworkElement element, Visual relativeTo) - { - return element.TransformToVisual(relativeTo) - .TransformBounds(System.Windows.Controls.Primitives.LayoutInformation.GetLayoutSlot(element)); - } - - private void StartCapture() - { - var windowHandle = new System.Windows.Interop.WindowInteropHelper(FindWindow()).Handle; - - var hr = NativeMethods.DwmRegisterThumbnail(windowHandle, PreviewHandle, out _dwmThumbnail); - if (hr != 0) - return; - } - - private Window FindWindow() => _window ??= Window.GetWindow(this); - - private void UpdateThumbnailProperties() - { - if (_dwmThumbnail == IntPtr.Zero || !IsConnectedToVisualTree()) - return; - - ApplyRect(ComputeDestinationRect()); - } - - private bool IsConnectedToVisualTree() - { - var window = FindWindow(); - return window != null && window.IsAncestorOf(this); - } - - private void OnRenderingTick(object? sender, EventArgs e) - { - if (_dwmThumbnail == IntPtr.Zero || !IsVisible || !IsConnectedToVisualTree()) - return; - - var rect = ComputeDestinationRect(); - if (_hasLastRect && rect.Top == _lastRect.Top && rect.Left == _lastRect.Left - && rect.Bottom == _lastRect.Bottom && rect.Right == _lastRect.Right) - return; - - ApplyRect(rect); - } - - private Win32.Rect ComputeDestinationRect() - { - var dpi = GetDpiScaleFactor(); - var previewBounds = BoundsRelativeTo(this, FindWindow()); - - return new Win32.Rect - { - Top = (int)(previewBounds.Top * dpi.Y), - Left = (int)(previewBounds.Left * dpi.X), - Bottom = (int)((previewBounds.Bottom - Margin.Top - Margin.Bottom) * dpi.Y) + 1, - Right = (int)((previewBounds.Right - Margin.Left - Margin.Right) * dpi.X) + 1 - }; - } - - private void ApplyRect(Win32.Rect rect) - { - var props = new DWM_THUMBNAIL_PROPERTIES - { - fVisible = true, - dwFlags = (int)(DWM_TNP.DWM_TNP_VISIBLE | DWM_TNP.DWM_TNP_OPACITY | DWM_TNP.DWM_TNP_RECTDESTINATION | DWM_TNP.DWM_TNP_SOURCECLIENTAREAONLY), - opacity = 255, - rcDestination = rect, - fSourceClientAreaOnly = true - }; - NativeMethods.DwmUpdateThumbnailProperties(_dwmThumbnail, ref props); - _lastRect = rect; - _hasLastRect = true; - } - } -} diff --git a/StageManager/MainWindow.xaml b/StageManager/MainWindow.xaml index 48899a5..fba25f8 100644 --- a/StageManager/MainWindow.xaml +++ b/StageManager/MainWindow.xaml @@ -24,7 +24,6 @@