diff --git a/CLAUDE.md b/CLAUDE.md
index 99c3416..b064d4b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,6 +1,15 @@
-# CLAUDE.md
+## Overview
-This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+A recreation of macOS Stage Manager for Windows, built on
+[awaescher/StageManager](https://github.com/awaescher/StageManager). Currently in beta; the goal is
+feature parity with macOS.
+
+Open windows are grouped by process into "scenes" listed on a sidebar. One scene is on stage at a time;
+the rest are parked off-screen. Clicking a scene switches to it, and windows can be dragged between
+scenes to reorganise the workspace. Sidebar tiles are live — each one is a real capture of the window,
+tilted and scaled to match the macOS card look.
+
+Single WPF executable, .NET 10, Windows 10 2004 or newer. No server, no database, no test project.
## Build & Run
@@ -14,7 +23,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,33 +32,24 @@ 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 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.
-- **[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).
-
-## P/Invoke Organization
-
-Win32 APIs are in `Native/PInvoke/` as partial classes on `Win32`:
-- `Win32.cs` — constants, enums, core functions
-- `Win32.Window.cs` — SetWindowPos, window positioning
-- `Win32.Long.cs` — Get/SetWindowLong, extended styles (WS_EX)
-- `Win32.WinEvent.cs` — SetWinEventHook, event constants
+## Codemaps
-DWM thumbnail APIs are in `Native/Interop/NativeMethods.cs`.
+Structure, dependencies and design decisions live in `codemaps/`, tracked in this repo. Read the
+relevant map before changing code in that area, and update it in the same commit when a change moves
+a boundary — new namespace, new dependency edge, changed public surface, changed geometry constant.
-## Animation System (WIP)
+- [architecture.md](codemaps/architecture.md) — layers, namespace dependency graph, runtime flow, build, known debt
+- [rendering.md](codemaps/rendering.md) — `Controls/`, `Animations/`, `Composition/`, `Converters/`, `Themes/`
+- [windowing.md](codemaps/windowing.md) — `SceneManager.cs`, `Native/`, `Strategies/`, `Services/`, `Helpers/`
+- [data.md](codemaps/data.md) — `Model/` and persistence
-`Animations/SceneTransitionAnimator.cs` uses a separate transparent topmost WPF window (`TransitionOverlayWindow`) as an overlay. Placeholder rectangles animate from sidebar position to window position (incoming) and vice versa (outgoing). Duration: 300ms, PowerEase EaseOut.
+Each map carries a freshness line with the commit it was generated against. Regenerate with
+`/update-codemaps`; the diff report lands in `.reports/codemap-diff.txt`, which is not tracked.
-The overlay has `WS_EX_TOOLWINDOW | WS_EX_TRANSPARENT` so it doesn't appear in Alt-Tab or intercept clicks.
## CI/CD
diff --git a/ReadMe.md b/ReadMe.md
index 3413911..f7105cb 100644
--- a/ReadMe.md
+++ b/ReadMe.md
@@ -4,7 +4,7 @@ A faithful recreation of macOS [Stage Manager](https://support.apple.com/en-us/H

-Groups windows by process into "scenes" shown on a sidebar. Switch scenes to focus on one group at a time while others are hidden. Drag windows between scenes to reorganize your workspace.
+Groups windows by process into "scenes" shown on a sidebar. Switch scenes to focus on one group at a time while others are hidden. Drag windows between scenes to reorganize your workspace. Sidebar previews are live and show each window's current contents.
## Usage
@@ -17,7 +17,8 @@ dotnet run --project StageManager
```
### Requirements
- - Windows 10 version 1607 or newer
+ - Windows 10 version 2004 (build 19041) or newer
+ - A GPU with Direct3D 11 support
- [.NET 10 SDK](https://dotnet.microsoft.com/en-us/download)
## Roadmap
@@ -25,10 +26,10 @@ dotnet run --project StageManager
The goal is a 1:1 match with macOS Stage Manager. Key remaining work:
- **Behaviour alignment** — match macOS scene switching logic, window grouping rules, and edge cases
-- **Complete animations** — smooth scene transitions, sidebar fly-in/fly-out, window shuffle effects
+- **Complete animations** — window shuffle effects, and remaining transition polish
- **Multi-monitor support** — independent stage managers per display
-- **Visual polish** — 3D perspective thumbnails, proper sizing relative to desktop, adaptive sidebar positioning
-- **Drag & drop refinement** — visual feedback, ghost previews, snap-to-scene indicators
+- **Visual polish** — adaptive sidebar positioning
+- **Drag & drop refinement** — snap-to-scene indicators
- **Smarter window detection** — filter out popups and transient windows (e.g. Teams call toasts) that shouldn't create new scenes
## Acknowledgements
diff --git a/StageManager/Animations/BorderCard.cs b/StageManager/Animations/BorderCard.cs
new file mode 100644
index 0000000..95682b6
--- /dev/null
+++ b/StageManager/Animations/BorderCard.cs
@@ -0,0 +1,43 @@
+using System;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Media;
+using StageManager.Helpers;
+
+namespace StageManager.Animations
+{
+ ///
+ /// Static fallback flying card: the icon placeholder Border on the overlay
+ /// canvas. Used when no live capture session is available for a card. The 3D
+ /// tilt is approximated by a horizontal scale (ScaleX = cos θ), matching the
+ /// rest of the proxy animations since .NET 10 WPF dropped PlaneProjection.
+ ///
+ internal sealed class BorderCard : IFlyingCard
+ {
+ private readonly TransitionOverlayWindow _overlay;
+ private readonly Border _border;
+
+ public BorderCard(TransitionOverlayWindow overlay, ImageSource? icon)
+ {
+ _overlay = overlay;
+ _border = PlaceholderFactory.Create(icon);
+ _overlay.Canvas.Children.Add(_border);
+ }
+
+ public void Update(Rect baseRect, double skewDegrees)
+ {
+ var c = baseRect.ToCanvas(_overlay);
+ Canvas.SetLeft(_border, c.X);
+ Canvas.SetTop(_border, c.Y);
+ _border.Width = Math.Max(1, c.Width);
+ _border.Height = Math.Max(1, c.Height);
+ if (_border.RenderTransform is ScaleTransform st)
+ st.ScaleX = Math.Cos(skewDegrees * Math.PI / 180.0);
+ }
+
+ public void SetVisible(bool visible) =>
+ _border.Visibility = visible ? Visibility.Visible : Visibility.Collapsed;
+
+ public void Release() => _overlay.Canvas.Children.Remove(_border);
+ }
+}
diff --git a/StageManager/Animations/DebugZoneOverlay.cs b/StageManager/Animations/DebugZoneOverlay.cs
index b195840..8c3bfaa 100644
--- a/StageManager/Animations/DebugZoneOverlay.cs
+++ b/StageManager/Animations/DebugZoneOverlay.cs
@@ -14,7 +14,7 @@ namespace StageManager.Animations
internal class DebugZoneOverlay
{
private readonly SceneTransitionAnimator _animator;
- private List _zones;
+ private List? _zones;
public DebugZoneOverlay(SceneTransitionAnimator animator)
{
diff --git a/StageManager/Animations/DragDropManager.cs b/StageManager/Animations/DragDropManager.cs
index 041492c..431709a 100644
--- a/StageManager/Animations/DragDropManager.cs
+++ b/StageManager/Animations/DragDropManager.cs
@@ -1,4 +1,5 @@
using StageManager.Animations;
+using StageManager.Controls;
using StageManager.Native.PInvoke;
using StageManager.Native.Window;
using System;
@@ -22,12 +23,14 @@ private enum DragState { None, TrackingWindowDrag, ShrinkingInBuffer }
private static readonly Rect TargetThumbSize = new Rect(0, 0, 120, 80);
private readonly SceneManager _sceneManager;
- private readonly DragGhostWindow _ghostWindow;
+ private readonly SidebarDragGhost _ghost;
private readonly Func _getDpiScale;
private readonly Func _getSidebarWidth;
+ private readonly Func _getOverlayBounds;
private readonly Func _getWindowLogicalRect;
- private readonly Func _getWindowIcon;
+ private readonly Func _getWindowIcon;
private readonly Action _syncVisibility;
+ private readonly double _cornerRadius;
private int _stateValue = (int)DragState.None;
private DragState State
@@ -36,38 +39,42 @@ private DragState State
set => Volatile.Write(ref _stateValue, (int)value);
}
- private IWindow _trackedWindow;
+ private IWindow? _trackedWindow;
private Rect _originalWindowRect;
private double _bufferRightPhysical;
private double _sidebarWidthPhysical;
private Win32.WS _originalStyle;
- private DispatcherTimer _pollTimer;
+ private DispatcherTimer? _pollTimer;
public bool IsDragging => State != DragState.None;
public DragDropManager(
SceneManager sceneManager,
- DragGhostWindow ghostWindow,
+ SidebarDragGhost ghost,
Func getDpiScale,
Func getSidebarWidth,
+ Func getOverlayBounds,
Func getWindowLogicalRect,
- Func getWindowIcon,
- Action syncVisibility)
+ Func getWindowIcon,
+ Action syncVisibility,
+ double cornerRadius)
{
_sceneManager = sceneManager;
- _ghostWindow = ghostWindow;
+ _ghost = ghost;
_getDpiScale = getDpiScale;
_getSidebarWidth = getSidebarWidth;
+ _getOverlayBounds = getOverlayBounds;
_getWindowLogicalRect = getWindowLogicalRect;
_getWindowIcon = getWindowIcon;
_syncVisibility = syncVisibility;
+ _cornerRadius = cornerRadius;
}
public void OnWindowMoveStart(IWindow window)
{
if (State != DragState.None) return;
var scene = _sceneManager.FindSceneForWindow(window);
- if (!_sceneManager.IsCurrentScene(scene))
+ if (scene is null || !_sceneManager.IsCurrentScene(scene))
return;
if (scene.Windows.Count() <= 1)
return;
@@ -99,10 +106,12 @@ private void EnterBufferZone(IWindow window)
}
Log.Info("DRAG", $"Entered buffer zone (windowRect={windowRect})");
+ // Park off-screen (NOT alpha→0): WGC captures DWM post-alpha, so a hidden-by-alpha
+ // window yields transparent frames. Off-screen + full alpha keeps the live card fed.
HideRealWindow(window);
var icon = _getWindowIcon(window);
- _ghostWindow.Show(windowRect.X, windowRect.Y, windowRect.Width, windowRect.Height, icon);
+ _ghost.ShowOwned(_getOverlayBounds(), windowRect, window.Handle, icon, _getDpiScale(), _cornerRadius);
}
public async void OnWindowMoveEnd(IWindow window)
@@ -126,7 +135,7 @@ public async void OnWindowMoveEnd(IWindow window)
if (dropCursor.X < _sidebarWidthPhysical)
{
Log.Window("DRAG", "Dropped in sidebar, separating window", window);
- _ghostWindow.Hide();
+ _ghost.Hide();
State = DragState.None;
_sceneManager.SeparateWindowToNewScene(window);
@@ -137,7 +146,7 @@ await Dispatcher.CurrentDispatcher.InvokeAsync(() => { },
else
{
Log.Info("DRAG", "Dropped in buffer zone, cancelling");
- _ghostWindow.Hide();
+ _ghost.Hide();
RestoreRealWindow(window);
}
}
@@ -174,7 +183,7 @@ private void StopPolling()
}
}
- private void PollTick(object sender, EventArgs e)
+ private void PollTick(object? sender, EventArgs e)
{
if (_trackedWindow == null)
{
@@ -208,6 +217,12 @@ private void PollTick(object sender, EventArgs e)
return;
}
+ // The OS modal move-loop re-pins the real window to the cursor every frame,
+ // fighting the off-screen park. Re-assert it each tick so only the live ghost
+ // shows. Hide() re-applies the off-screen SetWindowPos (saved rect kept from
+ // the first park), and the window stays composited so WGC keeps feeding frames.
+ _sceneManager.ParkWindow(_trackedWindow);
+
// Interpolate ghost size: t=0 at buffer edge, t=1 at sidebar edge
var bufferWidth = _bufferRightPhysical - _sidebarWidthPhysical;
var t = Math.Clamp((_bufferRightPhysical - mouseX) / bufferWidth, 0.0, 1.0);
@@ -219,13 +234,17 @@ private void PollTick(object sender, EventArgs e)
var ghostX = mouseX / dpi.X - ghostW / 2;
var ghostY = mouseY / dpi.Y - ghostH / 2;
- _ghostWindow.Update(ghostX, ghostY, ghostW, ghostH);
+ // Flat on stage (t=0) → full tray tilt at the sidebar edge (t=1), matching the
+ // resting tray card so the handoff into the tray has no pop.
+ var skew = Lerp(0.0, CompositionThumbnail.TrayTiltDegrees, t);
+ _ghost.UpdatePositionAndSize(ghostX, ghostY, ghostW, ghostH, skew);
}
private void ExitBufferZone()
{
+ if (_trackedWindow is null) return;
Log.Info("DRAG", "Exited buffer zone (cursor moved right)");
- _ghostWindow.Hide();
+ _ghost.Hide();
Win32.SetWindowStyleLongPtr(_trackedWindow.Handle, _originalStyle);
RestoreRealWindow(_trackedWindow);
State = DragState.TrackingWindowDrag;
@@ -234,14 +253,14 @@ private void ExitBufferZone()
private void HideRealWindow(IWindow window)
{
- Win32Helper.SetAlpha(window.Handle, 0);
- Log.Window("DRAG", "Hidden (alpha→0)", window);
+ _sceneManager.ParkWindow(window);
+ Log.Window("DRAG", "Parked off-screen (alpha intact for WGC)", window);
}
private void RestoreRealWindow(IWindow window)
{
- Win32Helper.SetAlpha(window.Handle, 255);
- Log.Window("DRAG", "Restored (alpha→255)", window);
+ _sceneManager.RestoreWindow(window);
+ Log.Window("DRAG", "Restored to saved on-stage rect", window);
}
private void Reset()
@@ -253,7 +272,7 @@ private void Reset()
{
Win32.SetWindowStyleLongPtr(_trackedWindow.Handle, _originalStyle);
RestoreRealWindow(_trackedWindow);
- _ghostWindow.Hide();
+ _ghost.Hide();
}
catch { }
}
diff --git a/StageManager/Animations/DragGhostWindow.cs b/StageManager/Animations/DragGhostWindow.cs
index eac3dba..508367d 100644
--- a/StageManager/Animations/DragGhostWindow.cs
+++ b/StageManager/Animations/DragGhostWindow.cs
@@ -15,9 +15,9 @@ namespace StageManager.Animations
internal class DragGhostWindow : IDisposable
{
private Thread _thread;
- private Dispatcher _dispatcher;
+ private Dispatcher _dispatcher = null!;
private readonly ManualResetEventSlim _ready = new();
- private Window _window;
+ private Window _window = null!;
private volatile bool _disposed;
public DragGhostWindow()
@@ -65,12 +65,12 @@ private void RunOverlayThread()
///
/// Shows the drag ghost at the given logical coordinates with the specified icon.
///
- public void Show(double x, double y, double w, double h, ImageSource icon)
+ public void Show(double x, double y, double w, double h, ImageSource? icon)
{
if (_disposed) return;
// Freeze the icon so it can cross thread boundaries
- ImageSource frozenIcon = null;
+ ImageSource? frozenIcon = null;
if (icon != null)
{
frozenIcon = icon.IsFrozen ? icon : icon.CloneCurrentValue();
diff --git a/StageManager/Animations/IFlyingCard.cs b/StageManager/Animations/IFlyingCard.cs
new file mode 100644
index 0000000..a0107ab
--- /dev/null
+++ b/StageManager/Animations/IFlyingCard.cs
@@ -0,0 +1,22 @@
+using System.Windows;
+
+namespace StageManager.Animations
+{
+ ///
+ /// A card that travels between the sidebar and the stage during a transition.
+ /// Backed either by the tray tile's live capture () or
+ /// a static icon placeholder (). The driver (cursor drag
+ /// or timed fly) calls each frame with the current rect and
+ /// 3D tilt, then once when finished.
+ ///
+ internal interface IFlyingCard
+ {
+ /// Card rect in logical screen units.
+ /// 3D Y-tilt (tray angle in the sidebar, 0 flat on stage).
+ void Update(Rect baseRect, double skewDegrees);
+
+ void SetVisible(bool visible);
+
+ void Release();
+ }
+}
diff --git a/StageManager/Animations/LiveCardHost.cs b/StageManager/Animations/LiveCardHost.cs
new file mode 100644
index 0000000..f6bdf41
--- /dev/null
+++ b/StageManager/Animations/LiveCardHost.cs
@@ -0,0 +1,226 @@
+using System;
+using System.Numerics;
+using System.Windows;
+using System.Windows.Controls;
+using StageManager.Composition;
+using StageManager.Controls;
+using StageManager.Helpers;
+
+namespace StageManager.Animations
+{
+ ///
+ /// A flying card backed by a live Windows.Graphics.Capture session, re-hosted on
+ /// the transition overlay so the very frames the user sees travel and skew with
+ /// the card. Two flavours:
+ ///
+ /// - Borrowed — lends a tray tile's running session ();
+ /// hands the visual back to the tile.
+ /// - Owned — spins up a fresh session for a window with no tray tile
+ /// (, e.g. the current scene leaving the stage);
+ /// disposes it.
+ ///
+ ///
+ internal sealed class LiveCardHost : IFlyingCard
+ {
+ // Per-side HWND inflation so the perspective-skewed near edge isn't clipped.
+ // Single source of truth with the tray tile so the host rect matches at handoff.
+ private const double Headroom = CompositionThumbnail.HoverHeadroom;
+
+ private readonly TransitionOverlayWindow _overlay;
+ private readonly Point _dpi;
+ private readonly double _cornerRadius;
+
+ private CompositionHost? _host;
+ private CompositionThumbnail? _borrowedTile;
+ private CaptureSession? _ownedSession;
+ private CaptureSession? _session;
+
+ // The tray tile's resting edge angles, captured at borrow. The card interpolates
+ // these to (0,0) as it flies to the stage, re-solving the sprite rotation against
+ // its CURRENT height every frame — see SetEdgeShape. Zero for owned cards (no
+ // tile to inherit from), which stay flat.
+ private double _restTopEdgeDegrees;
+ private double _restBottomEdgeDegrees;
+ private double _lastAppliedRotationDegrees = double.NaN;
+
+ private LiveCardHost(TransitionOverlayWindow overlay, Point dpi, double cornerRadius)
+ {
+ _overlay = overlay;
+ _dpi = new Point(dpi.X <= 0 ? 1 : dpi.X, dpi.Y <= 0 ? 1 : dpi.Y);
+ _cornerRadius = cornerRadius;
+ }
+
+ /// Borrow a tray tile's live visual, or null if it has no session yet.
+ public static LiveCardHost? TryBorrow(TransitionOverlayWindow overlay, CompositionThumbnail? tile, Point dpi, double cornerRadius)
+ {
+ var visual = tile?.BorrowRootVisual();
+ if (visual is null || tile!.Session is null) return null;
+
+ var card = new LiveCardHost(overlay, dpi, cornerRadius)
+ {
+ _borrowedTile = tile,
+ _session = tile.Session,
+ // Inherit the tile's resting trapezoid so the card leaves the tray with
+ // exactly the tile's shape (no pop at handoff) and unfolds from there.
+ _restTopEdgeDegrees = tile.TopEdgeDegrees,
+ _restBottomEdgeDegrees = tile.BottomEdgeDegrees,
+ };
+ card.Mount(visual);
+ return card;
+ }
+
+ /// Capture a window that has no tray tile into a fresh owned session.
+ public static LiveCardHost? TryCreateOwned(TransitionOverlayWindow overlay, IntPtr hwnd, Point dpi, double cornerRadius)
+ {
+ if (hwnd == IntPtr.Zero) return null;
+
+ try
+ {
+ var card = new LiveCardHost(overlay, dpi, cornerRadius);
+ card._host = new CompositionHost();
+ overlay.Canvas.Children.Add(card._host);
+
+ var compositor = card._host.Compositor;
+ var devices = D3DDeviceHolder.GetOrCreate(compositor);
+ var session = new CaptureSession(hwnd, compositor, devices);
+ session.Start();
+
+ card._ownedSession = session;
+ card._session = session;
+ card._host.Root = session.RootVisual;
+ return card;
+ }
+ catch (Exception ex)
+ {
+ Log.Info("ANIM", $"LiveCardHost owned-capture failed for 0x{hwnd:X}: {ex.Message}");
+ return null;
+ }
+ }
+
+ private void Mount(Windows.UI.Composition.Visual visual)
+ {
+ _host = new CompositionHost();
+ _overlay.Canvas.Children.Add(_host);
+ _host.Root = visual;
+
+ // The tile normally seeds this, but state it once here so the card's camera
+ // never depends on the tile having run a size pass. Constant for the whole
+ // flight — SetEdgeShape only varies the sprite rotation against it.
+ _session?.SetPerspective((float)CompositionThumbnail.PerspectiveDepthPx);
+ }
+
+ public void Update(Rect baseRect, double skewDegrees)
+ {
+ if (_host is null || _session is null) return;
+
+ // Oversize the host (centred on the base rect) so the skewed near edge
+ // has slack inside the child HWND.
+ double hostW = baseRect.Width * (1.0 + 2.0 * Headroom);
+ double hostH = baseRect.Height * (1.0 + 2.0 * Headroom);
+ double cx = baseRect.X + baseRect.Width / 2.0;
+ double cy = baseRect.Y + baseRect.Height / 2.0;
+ var canvas = new Point(cx - hostW / 2.0, cy - hostH / 2.0).ToCanvas(_overlay);
+
+ Canvas.SetLeft(_host, canvas.X);
+ Canvas.SetTop(_host, canvas.Y);
+ _host.Width = hostW;
+ _host.Height = hostH;
+
+ float baseWpx = (float)(baseRect.Width * _dpi.X);
+ float baseHpx = (float)(baseRect.Height * _dpi.Y);
+ float hwndWpx = (float)(hostW * _dpi.X);
+ float hwndHpx = (float)(hostH * _dpi.Y);
+
+ _session.SetVisualSize(new Vector2(hwndWpx, hwndHpx), new Vector2(baseWpx, baseHpx));
+ _session.SetCornerRadius((float)(_cornerRadius * _dpi.X), new Vector2(baseWpx, baseHpx));
+ SetEdgeShape(skewDegrees, baseHpx, hwndWpx, hwndHpx);
+ }
+
+ ///
+ /// Re-solves the card's trapezoid against its CURRENT height, every frame.
+ ///
+ /// This is the whole point. The sprite's native Y-rotation and the root's fixed
+ /// vanishing distance
+ /// produce convergence Q = H*tan(theta)/(2*depth) — Q grows with the sprite. The
+ /// card previously kept the rotation the tray tile solved for a ~200px-tall
+ /// sprite while growing to a ~1600px-tall one, so the perspective divide ran
+ /// away: at stage size one vertical edge scaled ~1.85x and the other ~0.69x, a
+ /// 2.7:1 trapezoid taller than the display. Solving from the live height keeps
+ /// the requested edge angles exact at every size, which is exactly what
+ /// CompositionThumbnail does for the resting tile.
+ ///
+ ///
+ private void SetEdgeShape(double skewDegrees, float baseHpx, float hwndWpx, float hwndHpx)
+ {
+ if (_session is null) return;
+
+ // Owned cards have no tile to inherit a trapezoid from. Keep their original
+ // behaviour — skewDegrees straight through as a plain shear, no rotation, so
+ // the stage→tray card still tilts as it flies.
+ if (_restTopEdgeDegrees == 0.0 && _restBottomEdgeDegrees == 0.0)
+ {
+ _session.SetTransformMatrix(
+ CompositionThumbnail.ComposeTransform(skewDegrees, 1.0, 0, 0, hwndWpx, hwndHpx));
+ return;
+ }
+
+ // skewDegrees carries how much "tray-ness" is left: TrayTiltDegrees at the
+ // tray end of the flight, 0 at the stage end. Reuse it as the interpolation
+ // fraction for the inherited edge angles so the trapezoid flattens in
+ // lockstep with the flight and lands perfectly square on the stage.
+ double trayFraction = CompositionThumbnail.TrayTiltDegrees > 0.0
+ ? Math.Clamp(skewDegrees / CompositionThumbnail.TrayTiltDegrees, 0.0, 1.0)
+ : 0.0;
+
+ var (shearDegrees, rotationDegrees) = CompositionThumbnail.SolveEdgeAngles(
+ _restTopEdgeDegrees * trayFraction,
+ _restBottomEdgeDegrees * trayFraction,
+ baseHpx);
+
+ // Dirty-check: one WinRT interop call per frame per card saved whenever the
+ // rotation is unchanged (notably the flat owned card, where it stays 0).
+ if (rotationDegrees != _lastAppliedRotationDegrees)
+ {
+ _lastAppliedRotationDegrees = rotationDegrees;
+ _session.SetSpriteRotationY((float)rotationDegrees);
+ }
+
+ _session.SetTransformMatrix(
+ CompositionThumbnail.ComposeTransform(shearDegrees, 1.0, 0, 0, hwndWpx, hwndHpx));
+ }
+
+ public void SetVisible(bool visible)
+ {
+ if (_host is not null)
+ _host.Visibility = visible ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ public void Release()
+ {
+ if (_host is not null)
+ {
+ // Detach the visual from the overlay BEFORE returning/disposing, so
+ // tearing down the overlay host can't take the tile's visual with it.
+ // Detach can throw E_INVALIDARG if the visual died under us (device
+ // lost, session torn down off-thread) — never let that kill the app.
+ try { _host.Root = null; }
+ catch (Exception ex) { Log.Info("ANIM", $"LiveCardHost detach threw: {ex.Message}"); }
+ _borrowedTile?.ReturnRootVisual();
+ _overlay.Canvas.Children.Remove(_host);
+ try { _host.Dispose(); }
+ catch (Exception ex) { Log.Info("ANIM", $"LiveCardHost dispose threw: {ex.Message}"); }
+ _host = null;
+ }
+
+ if (_ownedSession is not null)
+ {
+ try { _ownedSession.Dispose(); }
+ catch (Exception ex) { Log.Info("ANIM", $"LiveCardHost owned-session dispose threw: {ex.Message}"); }
+ _ownedSession = null;
+ }
+
+ _borrowedTile = null;
+ _session = null;
+ }
+ }
+}
diff --git a/StageManager/Animations/PlaceholderFactory.cs b/StageManager/Animations/PlaceholderFactory.cs
index 418f756..9781369 100644
--- a/StageManager/Animations/PlaceholderFactory.cs
+++ b/StageManager/Animations/PlaceholderFactory.cs
@@ -14,13 +14,20 @@ internal static class PlaceholderFactory
private const double ShadowDepthValue = 2;
private const double ShadowOpacity = 0.4;
- public static Border Create(ImageSource icon)
+ public static Border Create(ImageSource? icon)
{
return new Border
{
Background = new SolidColorBrush(Background),
CornerRadius = new CornerRadius(CornerRadiusValue),
ClipToBounds = false,
+ // Tilt slot. .NET 10 WPF dropped PlaneProjection, so the tray's
+ // 3D Y-rotation is approximated on proxies by a horizontal scale
+ // (ScaleX = cos θ — the orthographic projection of that rotation).
+ // Identity by default; the scene-switch animator + drag ghosts
+ // drive ScaleX about the element centre.
+ RenderTransformOrigin = new Point(0.5, 0.5),
+ RenderTransform = new ScaleTransform(1, 1),
Child = new Image
{
Source = icon,
diff --git a/StageManager/Animations/SceneTransitionAnimator.cs b/StageManager/Animations/SceneTransitionAnimator.cs
index 2f759fb..8214232 100644
--- a/StageManager/Animations/SceneTransitionAnimator.cs
+++ b/StageManager/Animations/SceneTransitionAnimator.cs
@@ -2,8 +2,9 @@
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
-using System.Windows.Controls;
+using System.Windows.Media;
using System.Windows.Media.Animation;
+using StageManager.Controls;
using StageManager.Helpers;
using StageManager.Model;
@@ -13,12 +14,12 @@ internal class SceneTransitionAnimator : IDisposable
{
private const int AnimationDurationMs = 300;
- private TransitionOverlayWindow _overlay;
+ private TransitionOverlayWindow? _overlay;
private bool _isAnimating;
public bool IsAnimating => _isAnimating;
- internal TransitionOverlayWindow Overlay => _overlay;
+ internal TransitionOverlayWindow? Overlay => _overlay;
internal TransitionOverlayWindow GetOrCreateOverlay(Rect bounds)
{
@@ -38,75 +39,57 @@ public void WarmUp(Rect bounds)
}
///
- /// Animates placeholders for both the incoming and outgoing scenes simultaneously.
+ /// Flies the incoming and outgoing scenes simultaneously as live cards: the
+ /// incoming scene (clicked) travels sidebar → stage unskewing 31° → flat; the
+ /// outgoing scene (current) travels stage → sidebar skewing flat → 31°. The
+ /// incoming card borrows its tray tile's capture; the outgoing card — which
+ /// has no tray tile while current — captures its window into an owned session.
+ /// Either side falls back to a static icon card when no capture is available.
/// Pass Rect.Empty for outgoingSource to skip the outgoing animation.
///
public Task AnimateSceneTransitionAsync(
Rect overlayBounds,
- Rect incomingSource, Rect incomingTarget, SceneModel incomingScene,
- Rect outgoingSource, Rect outgoingTarget, SceneModel outgoingScene)
+ Rect incomingSource, Rect incomingTarget, SceneModel incomingScene, CompositionThumbnail? incomingTile,
+ Rect outgoingSource, Rect outgoingTarget, SceneModel? outgoingScene, IntPtr outgoingHandle,
+ Point dpi, double cornerRadius)
{
if (_isAnimating) return Task.CompletedTask;
_isAnimating = true;
var tcs = new TaskCompletionSource();
- // Hoisted so the catch block can clean up partial state (placeholders already
- // added to Canvas.Children before the exception). Otherwise they leak forever
- // and stale ghost rectangles compound across failures.
- Border inPlaceholder = null;
- Border outPlaceholder = null;
+ // Hoisted so the catch block can release cards already added to the overlay.
+ IFlyingCard? incoming = null;
+ IFlyingCard? outgoing = null;
try
{
EnsureOverlay(overlayBounds);
- var duration = new Duration(TimeSpan.FromMilliseconds(AnimationDurationMs));
- var easing = new PowerEase { EasingMode = EasingMode.EaseOut };
- var storyboard = new Storyboard();
+ incoming = LiveCardHost.TryBorrow(_overlay, incomingTile, dpi, cornerRadius) as IFlyingCard
+ ?? new BorderCard(_overlay, incomingScene?.Windows.FirstOrDefault()?.Icon);
+ incoming.Update(incomingSource, CompositionThumbnail.TrayTiltDegrees);
+ Log.Info("ANIM", $"Incoming: {Fmt(incomingSource)} → {Fmt(incomingTarget)} (live={incoming is LiveCardHost})");
- // --- Incoming placeholder (sidebar → window position) ---
- var inIcon = incomingScene?.Windows.FirstOrDefault()?.Icon;
- inPlaceholder = PlaceholderFactory.Create(inIcon);
- var inFrom = incomingSource.ToCanvas(_overlay);
- var inTo = incomingTarget.ToCanvas(_overlay);
- SetupPlaceholder(inPlaceholder, storyboard, duration, easing, inFrom, inTo);
- _overlay.Canvas.Children.Add(inPlaceholder);
-
- Log.Info("ANIM", $"Incoming: ({inFrom.X:F0},{inFrom.Y:F0} {inFrom.Width:F0}x{inFrom.Height:F0}) → ({inTo.X:F0},{inTo.Y:F0} {inTo.Width:F0}x{inTo.Height:F0})");
-
- // --- Outgoing placeholder (window position → sidebar) ---
- if (outgoingSource != Rect.Empty && outgoingScene != null)
+ bool hasOutgoing = outgoingSource != Rect.Empty && outgoingScene != null;
+ if (hasOutgoing)
{
- var outIcon = outgoingScene.Windows.FirstOrDefault()?.Icon;
- outPlaceholder = PlaceholderFactory.Create(outIcon);
- var outFrom = outgoingSource.ToCanvas(_overlay);
- var outTo = outgoingTarget.ToCanvas(_overlay);
- SetupPlaceholder(outPlaceholder, storyboard, duration, easing, outFrom, outTo);
- _overlay.Canvas.Children.Add(outPlaceholder);
-
- Log.Info("ANIM", $"Outgoing: ({outFrom.X:F0},{outFrom.Y:F0} {outFrom.Width:F0}x{outFrom.Height:F0}) → ({outTo.X:F0},{outTo.Y:F0} {outTo.Width:F0}x{outTo.Height:F0})");
+ outgoing = LiveCardHost.TryCreateOwned(_overlay, outgoingHandle, dpi, cornerRadius) as IFlyingCard
+ ?? new BorderCard(_overlay, outgoingScene!.Windows.FirstOrDefault()?.Icon);
+ outgoing.Update(outgoingSource, 0.0);
+ Log.Info("ANIM", $"Outgoing: {Fmt(outgoingSource)} → {Fmt(outgoingTarget)} (live={outgoing is LiveCardHost})");
}
- Log.Info("ANIM", $"Overlay: {_overlay.Left:F0},{_overlay.Top:F0} {_overlay.Width:F0}x{_overlay.Height:F0}, placeholders={_overlay.Canvas.Children.Count}");
_overlay.Show();
- storyboard.Completed += (s, e) =>
- {
- Log.Info("ANIM", "Storyboard completed, removing placeholders");
- if (inPlaceholder != null) _overlay.Canvas.Children.Remove(inPlaceholder);
- if (outPlaceholder != null) _overlay.Canvas.Children.Remove(outPlaceholder);
- if (_overlay.Canvas.Children.Count == 0) _overlay.Hide();
- _isAnimating = false;
- tcs.TrySetResult(true);
- };
-
- storyboard.Begin();
+ RunFlight(tcs,
+ incoming, incomingSource, incomingTarget,
+ outgoing, outgoingSource, outgoingTarget);
}
catch (Exception ex)
{
Log.Info("ANIM", $"Transition failed: {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}");
- if (inPlaceholder != null) _overlay?.Canvas.Children.Remove(inPlaceholder);
- if (outPlaceholder != null) _overlay?.Canvas.Children.Remove(outPlaceholder);
+ incoming?.Release();
+ outgoing?.Release();
_isAnimating = false;
_overlay?.Hide();
tcs.TrySetResult(false);
@@ -115,25 +98,73 @@ public Task AnimateSceneTransitionAsync(
return tcs.Task;
}
+ ///
+ /// Drives both cards from a per-frame rendering tick over .
+ /// A Storyboard can move the host rect but can't animate the perspective matrix,
+ /// so size + 3D tilt are interpolated here and pushed to the live session each frame.
+ ///
+ private void RunFlight(TaskCompletionSource tcs,
+ IFlyingCard? incoming, Rect inFrom, Rect inTo,
+ IFlyingCard? outgoing, Rect outFrom, Rect outTo)
+ {
+ var easing = new PowerEase { EasingMode = EasingMode.EaseOut };
+ double durationMs = AnimationDurationMs;
+ TimeSpan? start = null;
+ EventHandler? handler = null;
+
+ void Finish(bool ok)
+ {
+ CompositionTarget.Rendering -= handler;
+ incoming?.Release();
+ outgoing?.Release();
+ if (_overlay != null && _overlay.Canvas.Children.Count == 0) _overlay.Hide();
+ _isAnimating = false;
+ tcs.TrySetResult(ok);
+ }
+
+ handler = (s, e) =>
+ {
+ // A throw inside the rendering tick must still unsubscribe + release,
+ // otherwise the handler leaks and _isAnimating stays true forever.
+ try
+ {
+ var now = ((RenderingEventArgs)e).RenderingTime;
+ start ??= now;
+ double u = Math.Clamp((now - start.Value).TotalMilliseconds / durationMs, 0.0, 1.0);
+ double k = easing.Ease(u);
+
+ incoming?.Update(LerpRect(inFrom, inTo, k), Lerp(CompositionThumbnail.TrayTiltDegrees, 0.0, k));
+ outgoing?.Update(LerpRect(outFrom, outTo, k), Lerp(0.0, CompositionThumbnail.TrayTiltDegrees, k));
+
+ if (u >= 1.0)
+ {
+ Log.Info("ANIM", "Flight completed");
+ Finish(true);
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.Info("ANIM", $"Flight tick failed, aborting: {ex.Message}");
+ Finish(false);
+ }
+ };
+
+ CompositionTarget.Rendering += handler;
+ }
+
+ [System.Diagnostics.CodeAnalysis.MemberNotNull(nameof(_overlay))]
private void EnsureOverlay(Rect bounds)
{
_overlay ??= new TransitionOverlayWindow();
_overlay.PositionFrom(bounds);
}
- private static void SetupPlaceholder(Border placeholder, Storyboard storyboard,
- Duration duration, IEasingFunction easing, Rect from, Rect to)
- {
- Canvas.SetLeft(placeholder, from.X);
- Canvas.SetTop(placeholder, from.Y);
- placeholder.Width = from.Width;
- placeholder.Height = from.Height;
-
- storyboard.Children.Add(Anim.Storyboard(from.X, to.X, duration, easing, placeholder, Canvas.LeftProperty));
- storyboard.Children.Add(Anim.Storyboard(from.Y, to.Y, duration, easing, placeholder, Canvas.TopProperty));
- storyboard.Children.Add(Anim.Storyboard(from.Width, to.Width, duration, easing, placeholder, FrameworkElement.WidthProperty));
- storyboard.Children.Add(Anim.Storyboard(from.Height, to.Height, duration, easing, placeholder, FrameworkElement.HeightProperty));
- }
+ private static double Lerp(double a, double b, double t) => DragDropManager.Lerp(a, b, t);
+
+ private static Rect LerpRect(Rect a, Rect b, double t) =>
+ new Rect(Lerp(a.X, b.X, t), Lerp(a.Y, b.Y, t), Lerp(a.Width, b.Width, t), Lerp(a.Height, b.Height, t));
+
+ private static string Fmt(Rect r) => $"({r.X:F0},{r.Y:F0} {r.Width:F0}x{r.Height:F0})";
public void Dispose()
{
diff --git a/StageManager/Animations/SidebarDragGhost.cs b/StageManager/Animations/SidebarDragGhost.cs
index 6e0ebc1..4429044 100644
--- a/StageManager/Animations/SidebarDragGhost.cs
+++ b/StageManager/Animations/SidebarDragGhost.cs
@@ -1,21 +1,22 @@
using System;
using System.Linq;
using System.Windows;
-using System.Windows.Controls;
-using StageManager.Helpers;
+using System.Windows.Media;
+using StageManager.Controls;
using StageManager.Model;
namespace StageManager.Animations
{
///
- /// Manages the drag ghost for WPF sidebar drag (Flow 2: sidebar → active screen).
- /// Borrows the overlay from SceneTransitionAnimator to render a placeholder
- /// that follows the cursor during drag.
+ /// Drag ghost for the sidebar → active-screen flow. Borrows the tray tile's
+ /// live capture (so the very frames the user sees travel and unskew with the
+ /// cursor), falling back to a static icon card when the tile has no running
+ /// session yet. Both paths share .
///
internal class SidebarDragGhost
{
private readonly SceneTransitionAnimator _animator;
- private Border _ghost;
+ private IFlyingCard? _card;
private bool _isActive;
public bool IsActive => _isActive;
@@ -25,7 +26,8 @@ public SidebarDragGhost(SceneTransitionAnimator animator)
_animator = animator;
}
- public void Show(Rect overlayBounds, Rect ghostRect, SceneModel scene)
+ public void Show(Rect overlayBounds, Rect ghostRect, SceneModel scene,
+ CompositionThumbnail? tile, Point dpi, double cornerRadius)
{
if (_animator.IsAnimating) return;
_isActive = true;
@@ -33,18 +35,10 @@ public void Show(Rect overlayBounds, Rect ghostRect, SceneModel scene)
try
{
var overlay = _animator.GetOrCreateOverlay(overlayBounds);
-
- var icon = scene?.Windows.FirstOrDefault()?.Icon;
- _ghost = PlaceholderFactory.Create(icon);
- var ghostCanvas = ghostRect.ToCanvas(overlay);
- Canvas.SetLeft(_ghost, ghostCanvas.X);
- Canvas.SetTop(_ghost, ghostCanvas.Y);
- _ghost.Width = ghostCanvas.Width;
- _ghost.Height = ghostCanvas.Height;
-
- overlay.Canvas.Children.Add(_ghost);
+ _card = LiveCardHost.TryBorrow(overlay, tile, dpi, cornerRadius) as IFlyingCard
+ ?? new BorderCard(overlay, scene?.Windows.FirstOrDefault()?.Icon);
+ _card.Update(ghostRect, CompositionThumbnail.TrayTiltDegrees);
overlay.Show();
- Log.Info("DRAG", $"Ghost shown at ({ghostRect.X:F0},{ghostRect.Y:F0} {ghostRect.Width:F0}x{ghostRect.Height:F0}) overlay=({overlayBounds.X:F0},{overlayBounds.Y:F0} {overlayBounds.Width:F0}x{overlayBounds.Height:F0})");
}
catch (Exception ex)
{
@@ -53,33 +47,52 @@ public void Show(Rect overlayBounds, Rect ghostRect, SceneModel scene)
}
}
- public void UpdatePositionAndSize(double screenX, double screenY, double width, double height)
+ ///
+ /// Owned variant for the stage→tray drag: the dragged window has no tray tile to
+ /// borrow, so capture it into a fresh session. Starts flat (0° — it's on stage);
+ /// the caller skews it toward the tray angle across the buffer. Falls back to a
+ /// static icon card when capture is unavailable.
+ ///
+ public void ShowOwned(Rect overlayBounds, Rect ghostRect, IntPtr hwnd,
+ ImageSource? icon, Point dpi, double cornerRadius)
{
- if (_ghost == null) return;
- var overlay = _animator.Overlay;
- if (overlay == null) return;
- var canvasPoint = new Point(screenX, screenY).ToCanvas(overlay);
- Canvas.SetLeft(_ghost, canvasPoint.X);
- Canvas.SetTop(_ghost, canvasPoint.Y);
- _ghost.Width = Math.Max(1, width);
- _ghost.Height = Math.Max(1, height);
+ if (_animator.IsAnimating) return;
+ _isActive = true;
+
+ try
+ {
+ var overlay = _animator.GetOrCreateOverlay(overlayBounds);
+ _card = LiveCardHost.TryCreateOwned(overlay, hwnd, dpi, cornerRadius) as IFlyingCard
+ ?? new BorderCard(overlay, icon);
+ _card.Update(ghostRect, 0.0);
+ overlay.Show();
+ }
+ catch (Exception ex)
+ {
+ Log.Info("DRAG", $"ShowOwned failed: {ex.Message}");
+ Hide();
+ }
}
- public void SetVisible(bool visible)
+ ///
+ /// Position/size the ghost. (screenX, screenY) is the base rect's top-left
+ /// in logical screen units; skewDegrees is the 3D Y-tilt (tray angle in the
+ /// sidebar, lerped to 0 across the buffer).
+ ///
+ public void UpdatePositionAndSize(double screenX, double screenY, double width, double height, double skewDegrees)
{
- if (_ghost == null) return;
- _ghost.Visibility = visible ? Visibility.Visible : Visibility.Collapsed;
+ _card?.Update(new Rect(screenX, screenY, Math.Max(1, width), Math.Max(1, height)), skewDegrees);
}
+ public void SetVisible(bool visible) => _card?.SetVisible(visible);
+
public void Hide()
{
- if (_ghost != null)
- {
- _animator.Overlay?.Canvas.Children.Remove(_ghost);
- _ghost = null;
- }
- if (_animator.Overlay != null && _animator.Overlay.Canvas.Children.Count == 0)
- _animator.Overlay.Hide();
+ var overlay = _animator.Overlay;
+ _card?.Release();
+ _card = null;
+ if (overlay is not null && overlay.Canvas.Children.Count == 0)
+ overlay.Hide();
_isActive = false;
Log.Info("DRAG", "Ghost hidden");
}
diff --git a/StageManager/Composition/CaptureSession.cs b/StageManager/Composition/CaptureSession.cs
new file mode 100644
index 0000000..78df15c
--- /dev/null
+++ b/StageManager/Composition/CaptureSession.cs
@@ -0,0 +1,584 @@
+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;
+
+ // Upper bound on every semaphore wait. Nothing under this lock is long-running
+ // (framepool build / WinRT dispose), so exceeding it means the holder is wedged
+ // — proceeding or bailing beats hanging the thread forever.
+ private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(2);
+
+ 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. Holds ONLY a pure perspective matrix (the
+ // "camera") so it foreshortens the sprite's native Y-rotation into the
+ // resting trapezoid. Opacity rides here too.
+ private ContainerVisual? _rootContainer;
+ // _contentVisual: HWND-sized child of the root. Carries the affine content
+ // transform (hover scale / per-row shear / cursor pull) — kept OFF the root
+ // so the root's matrix stays pure perspective (an affine+perspective matrix
+ // on one visual collapses to affine and never divides).
+ private ContainerVisual? _contentVisual;
+ // _spriteVisual: base-sized, centered inside _contentVisual via Offset.
+ // Owns the surface brush + rounded clip; carries the native Y-rotation.
+ 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;
+
+ // Cached so the pure perspective matrix on _rootContainer can be rebuilt
+ // when either the container size or the depth changes.
+ private Vector2 _hwndPixels;
+ private float _perspectiveDepthPx; // 0 = no perspective until SetPerspective
+
+ 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));
+ // Bounded — Start runs on the UI thread; an unbounded wait here stalls
+ // the message pump if a capture-thread teardown is wedged.
+ if (!sem.Wait(LockTimeout))
+ {
+ Log.Info("CAPSESS", $"Start timed out waiting on lock for 0x{_hwnd:X}");
+ return;
+ }
+ 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;
+
+ // Root is the externally-visible camera: pure perspective + opacity.
+ // The content visual below carries scale/shear/pull; sprite below that.
+ _rootContainer = _compositor.CreateContainerVisual();
+ _rootContainer.Size = Vector2.Zero;
+ _contentVisual = _compositor.CreateContainerVisual();
+ _contentVisual.Size = Vector2.Zero;
+ _contentVisual.Children.InsertAtTop(_spriteVisual);
+ _rootContainer.Children.InsertAtTop(_contentVisual);
+
+ 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;
+ _hwndPixels = hwndPixels;
+ if (_rootContainer is not null)
+ _rootContainer.Size = hwndPixels;
+ if (_contentVisual is not null)
+ _contentVisual.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);
+ // Pivot for the native Y-rotation (perspective) — the sprite's own center.
+ _spriteVisual.CenterPoint = new Vector3(basePixels.X * 0.5f, basePixels.Y * 0.5f, 0f);
+ }
+ if (_clipGeometry is not null)
+ _clipGeometry.Size = basePixels;
+ RebuildPerspective();
+ }
+ }
+
+ // The affine content transform (scale/shear/cursor-pull) — set on the
+ // content visual, NOT the root, so the root keeps a pure perspective matrix.
+ public void SetTransformMatrix(System.Numerics.Matrix4x4 transform)
+ {
+ lock (_frameLock)
+ {
+ if (_disposed || _contentVisual is null) return;
+ _contentVisual.TransformMatrix = transform;
+ }
+ }
+
+ ///
+ /// Constant native 3D rotation of the inner sprite about its vertical axis.
+ /// Combined with the perspective divide in the container's TransformMatrix
+ /// this foreshortens the card into the resting trapezoid — perspective on
+ /// the parent container, rotation on the child sprite, per the documented
+ /// Composition perspective pattern (a perspective matrix only foreshortens
+ /// a CHILD visual's 3D rotation, never its own).
+ ///
+ public void SetSpriteRotationY(float degrees)
+ {
+ lock (_frameLock)
+ {
+ if (_disposed || _spriteVisual is null) return;
+ _spriteVisual.RotationAxis = new Vector3(0f, 1f, 0f);
+ _spriteVisual.RotationAngleInDegrees = degrees;
+ }
+ }
+
+ ///
+ /// Sets the perspective vanishing-distance (px) for the pure perspective
+ /// matrix on _rootContainer that foreshortens the sprite's Y-rotation into
+ /// the resting trapezoid. Smaller = stronger; 0 disables perspective.
+ ///
+ public void SetPerspective(float depthPx)
+ {
+ lock (_frameLock)
+ {
+ if (_disposed) return;
+ _perspectiveDepthPx = depthPx;
+ RebuildPerspective();
+ }
+ }
+
+ // Builds T(-c)*P*T(c) (P.M34 = -1/depth) on _rootContainer, centered on the
+ // current container size. Caller holds _frameLock.
+ private void RebuildPerspective()
+ {
+ if (_rootContainer is null) return;
+ if (_perspectiveDepthPx <= 0f || _hwndPixels.X <= 0f || _hwndPixels.Y <= 0f)
+ {
+ _rootContainer.TransformMatrix = Matrix4x4.Identity;
+ return;
+ }
+ float cx = _hwndPixels.X * 0.5f;
+ float cy = _hwndPixels.Y * 0.5f;
+ var p = Matrix4x4.Identity;
+ p.M34 = -1f / _perspectiveDepthPx;
+ _rootContainer.TransformMatrix =
+ Matrix4x4.CreateTranslation(-cx, -cy, 0f) * p * Matrix4x4.CreateTranslation(cx, cy, 0f);
+ }
+
+ 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;
+ }
+ }
+
+ ///
+ /// Stops frame delivery. Never blocks the caller: Pause is driven from the UI
+ /// thread by IsVisibleChanged (including the WM_CLOSE cascade), and the per-hwnd
+ /// lock may be held by a capture thread. A blocking wait here stalls the message
+ /// pump — the observed "StageManager.exe is delaying system shutdown after
+ /// 5016 ms". On contention the teardown is finished on the threadpool.
+ ///
+ public void Pause()
+ {
+ if (_paused || _disposed) return;
+ var sem = _hwndLocks.GetOrAdd(_hwnd, _ => new SemaphoreSlim(1, 1));
+ if (!sem.Wait(0))
+ {
+ ThreadPool.QueueUserWorkItem(_ => PauseDeferred(sem));
+ return;
+ }
+ try { PauseLocked(); }
+ finally { sem.Release(); }
+ }
+
+ private void PauseDeferred(SemaphoreSlim sem)
+ {
+ // Threadpool callback: an escaping exception kills the process. The
+ // semaphore itself can be disposed out from under us by a concurrent
+ // Dispose() (which removes it from _hwndLocks), so guard the wait too.
+ bool held = false;
+ try
+ {
+ held = sem.Wait(LockTimeout);
+ if (!held) { Log.Info("CAPSESS", $"Pause timed out waiting on lock for 0x{_hwnd:X}"); return; }
+ PauseLocked();
+ }
+ catch (Exception ex) { Log.Info("CAPSESS", $"Deferred pause failed for 0x{_hwnd:X}: {ex.Message}"); }
+ finally { if (held) { try { sem.Release(); } catch { } } }
+ }
+
+ // Caller holds the per-hwnd semaphore.
+ private void PauseLocked()
+ {
+ if (_disposed || _paused) return;
+ // Set first: OnFrameArrived short-circuits on _paused, so frame work stops
+ // even if the WinRT teardown below fails.
+ _paused = true;
+ ReleaseCaptureObjects("Pause");
+ Log.Info("CAPSESS", $"Paused capture for 0x{_hwnd:X}");
+ }
+
+ ///
+ /// Disposes the framepool + session. Every call is wrapped: WinRT teardown
+ /// throws RPC_E_CANTCALLOUT_ININPUTSYNCCALL (0x8001010D) when it lands while
+ /// the thread is dispatching an input-synchronous message (WM_CLOSE), and the
+ /// process must not die there — dying mid-teardown is exactly what leaks the
+ /// remaining sessions and leaves DWM capturing with no consumer.
+ ///
+ private void ReleaseCaptureObjects(string origin)
+ {
+ if (_session is not null)
+ {
+ try { _session.Dispose(); }
+ catch (Exception ex) { Log.Info("CAPSESS", $"{origin}: session dispose threw for 0x{_hwnd:X}: {ex.Message}"); }
+ _session = null;
+ }
+ if (_framePool is not null)
+ {
+ try
+ {
+ _framePool.FrameArrived -= OnFrameArrived;
+ _framePool.Dispose();
+ }
+ catch (Exception ex) { Log.Info("CAPSESS", $"{origin}: framepool dispose threw for 0x{_hwnd:X}: {ex.Message}"); }
+ _framePool = null;
+ }
+ }
+
+ public void Resume()
+ {
+ if (!_paused || _disposed) return;
+ var sem = _hwndLocks.GetOrAdd(_hwnd, _ => new SemaphoreSlim(1, 1));
+ // Same no-block rule as Pause — Resume also runs on the UI thread.
+ if (!sem.Wait(0))
+ {
+ ThreadPool.QueueUserWorkItem(_ => ResumeDeferred(sem));
+ return;
+ }
+ try { ResumeLocked(); }
+ finally { sem.Release(); }
+ }
+
+ private void ResumeDeferred(SemaphoreSlim sem)
+ {
+ // See PauseDeferred — threadpool callback, must swallow everything.
+ bool held = false;
+ try
+ {
+ held = sem.Wait(LockTimeout);
+ if (!held) { Log.Info("CAPSESS", $"Resume timed out waiting on lock for 0x{_hwnd:X}"); return; }
+ ResumeLocked();
+ }
+ catch (Exception ex) { Log.Info("CAPSESS", $"Deferred resume failed for 0x{_hwnd:X}: {ex.Message}"); }
+ finally { if (held) { try { sem.Release(); } catch { } } }
+ }
+
+ private static void DisposeQuietly(IDisposable? d)
+ {
+ if (d is null) return;
+ try { d.Dispose(); }
+ catch (Exception ex) { Log.Info("CAPSESS", $"Visual dispose threw: {ex.Message}"); }
+ }
+
+ // Caller holds the per-hwnd semaphore.
+ private void ResumeLocked()
+ {
+ if (_disposed || _item is null || !_paused) return;
+ // A deferred Pause may have raced us; make sure nothing is left running
+ // before building a second framepool on the same item.
+ ReleaseCaptureObjects("Resume");
+ try { BuildPool(); }
+ catch (Exception ex)
+ {
+ Log.Info("CAPSESS", $"Resume: BuildPool threw for 0x{_hwnd:X}: {ex.Message}");
+ return;
+ }
+ _paused = false;
+ Log.Info("CAPSESS", $"Resumed capture for 0x{_hwnd:X}");
+ }
+
+ 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;
+ // Bounded, and we proceed even on timeout: _disposed is already set and
+ // _frameLock below still fences an in-flight blit, so the GPU teardown is
+ // safe. Hanging here instead would deadlock shutdown.
+ var held = sem is null || sem.Wait(LockTimeout);
+ if (!held) Log.Info("CAPSESS", $"Dispose timed out waiting on lock for 0x{_hwnd:X}, proceeding");
+ 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)
+ {
+ try { _item.Closed -= OnItemClosed; }
+ catch (Exception ex) { Log.Info("CAPSESS", $"Dispose: item unhook threw for 0x{_hwnd:X}: {ex.Message}"); }
+ }
+ // Wrapped — see ReleaseCaptureObjects: WinRT teardown throws
+ // 0x8001010D under an input-synchronous message (WM_CLOSE).
+ ReleaseCaptureObjects("Dispose");
+ _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.
+ // Each is individually wrapped: one visual already torn down
+ // under us must not abort the rest of the teardown.
+ DisposeQuietly(_spriteVisual); _spriteVisual = null;
+ DisposeQuietly(_surfaceBrush); _surfaceBrush = null;
+ DisposeQuietly(_clip); _clip = null;
+ DisposeQuietly(_clipGeometry); _clipGeometry = null;
+ DisposeQuietly(_contentVisual); _contentVisual = null;
+ DisposeQuietly(_rootContainer); _rootContainer = null;
+ DisposeQuietly(_surface); _surface = null;
+ _surfaceInterop = null;
+ }
+ Log.Info("CAPSESS", $"Disposed capture for 0x{_hwnd:X}");
+ }
+ finally
+ {
+ if (held) sem?.Release();
+ // Drop the per-hwnd semaphore so the static dictionary doesn't
+ // grow unbounded across short-lived windows. NOT disposed: a
+ // deferred Pause/Resume may still be waiting on it, and
+ // SemaphoreSlim.Dispose would hand them an ObjectDisposedException
+ // on a threadpool thread. Nothing here uses AvailableWaitHandle,
+ // so there is no unmanaged handle to reclaim.
+ if (sem is not null) _hwndLocks.TryRemove(_hwnd, out _);
+ }
+ }
+ }
+}
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..007331f
--- /dev/null
+++ b/StageManager/Controls/CompositionThumbnail.xaml.cs
@@ -0,0 +1,640 @@
+using System;
+using System.Collections.Generic;
+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
+ {
+ ///
+ /// Resting vertical-shear skew of tray thumbnails, in degrees. Single
+ /// source of truth: bound by XAML (x:Static) and reused by the scene-
+ /// switch animator so the flying placeholder matches the tray skew.
+ ///
+ public const double TrayTiltDegrees = 2.0;
+
+ ///
+ /// Vanishing distance (physical px) of the perspective divide (M34 = -1/depth)
+ /// that CaptureSession puts on the tile's root container. It foreshortens the
+ /// inner sprite's native vertical-axis rotation into the resting trapezoid.
+ /// Smaller = stronger. The rotation itself is no longer a constant: it is
+ /// solved per tile from the requested edge angles, see ApplySpriteRotation.
+ ///
+ public const double PerspectiveDepthPx = 220.0;
+
+ 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 double _lastAppliedRotationDegrees = double.NaN;
+ private float _lastAppliedOpacity = 1f;
+ // While true the live capture visual is on loan to the sidebar drag
+ // ghost, which drives the shared session's size/transform directly.
+ // The tile must stop touching the session or the two fight over
+ // _rootContainer's TransformMatrix every time a bound DP ticks.
+ private bool _borrowed;
+
+ // Teardown requested while the visual was lent out (e.g. the tile
+ // unloaded because its scene left the tray mid-flight). Disposing the
+ // session then would yank live visuals out from under the flying card
+ // (WinRT E_INVALIDARG on every later touch), so defer until return.
+ private bool _teardownPending;
+
+ // Each side. Total HWND inflation = 1 + 2 * HoverHeadroom. 30% per side
+ // covers worst-case lateral overflow at peak hover (scale 1.08 + pull)
+ // plus the 3D-tilt near-edge enlargement. Shared: LiveCardHost sizes the
+ // borrowed drag/fly card with the same headroom so the host rect matches
+ // the tile's and the skewed edge isn't clipped differently at handoff.
+ internal const double HoverHeadroom = 0.30;
+
+ // Every live tile, so shutdown can dispose their sessions up-front instead of
+ // discovering them one WM_CLOSE visibility flip at a time. UI-thread only.
+ private static readonly List s_live = new();
+
+ // Set by ShutdownAll before the window teardown cascade starts. Once true,
+ // no tile may touch WinRT again: the cascade runs inside an input-synchronous
+ // WM_CLOSE, where any outgoing cross-apartment call fails with
+ // RPC_E_CANTCALLOUT_ININPUTSYNCCALL (0x8001010D).
+ private static bool s_shuttingDown;
+
+ public CompositionThumbnail()
+ {
+ InitializeComponent();
+ Loaded += OnLoaded;
+ Unloaded += OnUnloaded;
+ IsVisibleChanged += OnIsVisibleChanged;
+ SizeChanged += OnSizeChanged;
+ s_live.Add(this);
+ }
+
+ ///
+ /// Disposes every live capture session while ordinary COM rules still apply —
+ /// call from Window.OnClosing, BEFORE the WM_CLOSE visual-tree cascade. Leaving
+ /// it to the cascade throws 0x8001010D, kills the process mid-teardown, and
+ /// strands the surviving GraphicsCaptureSessions: DWM keeps capturing those
+ /// windows at full refresh rate with no consumer, dragging the whole desktop.
+ ///
+ internal static void ShutdownAll()
+ {
+ if (s_shuttingDown) return;
+ s_shuttingDown = true;
+
+ // Snapshot — TeardownSession can unregister as it goes.
+ var live = s_live.ToArray();
+ s_live.Clear();
+ Log.Info("COMPTHUMB", $"Shutdown: disposing {live.Length} capture session(s)");
+
+ foreach (var tile in live)
+ {
+ // A borrowed tile would normally defer teardown; there is no borrower
+ // left to return the visual, so force it.
+ tile._borrowed = false;
+ try { tile.TeardownSession(); }
+ catch (Exception ex) { Log.Info("COMPTHUMB", $"Shutdown teardown threw: {ex.Message}"); }
+ }
+ }
+
+ 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();
+ }
+
+ ///
+ /// Angle of the tile's TOP edge in degrees, POSITIVE = its right end rises,
+ /// 0 = horizontal. Set per row from MainWindow's hardcoded tray table.
+ /// Together with it pins both edges of the
+ /// resting trapezoid exactly; the left/right sides stay vertical.
+ ///
+ public static readonly DependencyProperty TopEdgeDegreesProperty = DependencyProperty.Register(
+ nameof(TopEdgeDegrees),
+ typeof(double),
+ typeof(CompositionThumbnail),
+ new PropertyMetadata(0.0, OnTransformInputChanged));
+
+ public double TopEdgeDegrees
+ {
+ get => (double)GetValue(TopEdgeDegreesProperty);
+ set => SetValue(TopEdgeDegreesProperty, value);
+ }
+
+ ///
+ /// Angle of the tile's BOTTOM edge in degrees, POSITIVE = its right end rises.
+ /// Bottom above top (bottom > top) means the two edges converge to the
+ /// right — the receding-card look. Equal values = parallel edges, i.e. a pure
+ /// shear with no perspective at all.
+ ///
+ public static readonly DependencyProperty BottomEdgeDegreesProperty = DependencyProperty.Register(
+ nameof(BottomEdgeDegrees),
+ typeof(double),
+ typeof(CompositionThumbnail),
+ new PropertyMetadata(0.0, OnTransformInputChanged));
+
+ public double BottomEdgeDegrees
+ {
+ get => (double)GetValue(BottomEdgeDegreesProperty);
+ set => SetValue(BottomEdgeDegreesProperty, 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 TranslateTransform.X/Y onto the
+ // SpriteVisual. Values are in DIPs; ApplyTransform converts to pixels.
+ public static readonly DependencyProperty MirrorTranslateXProperty = DependencyProperty.Register(
+ nameof(MirrorTranslateX),
+ typeof(double),
+ typeof(CompositionThumbnail),
+ new PropertyMetadata(0.0, OnTransformInputChanged));
+
+ public double MirrorTranslateX
+ {
+ get => (double)GetValue(MirrorTranslateXProperty);
+ set => SetValue(MirrorTranslateXProperty, value);
+ }
+
+ public static readonly DependencyProperty MirrorTranslateYProperty = DependencyProperty.Register(
+ nameof(MirrorTranslateY),
+ typeof(double),
+ typeof(CompositionThumbnail),
+ new PropertyMetadata(0.0, OnTransformInputChanged));
+
+ public double MirrorTranslateY
+ {
+ get => (double)GetValue(MirrorTranslateYProperty);
+ set => SetValue(MirrorTranslateYProperty, 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)
+ {
+ // Closing the window makes WPF flip IsVisible on the whole visual tree
+ // from inside WM_CLOSE. Sessions are already gone (ShutdownAll ran in
+ // OnClosing) and any WinRT call from here would throw 0x8001010D.
+ if (s_shuttingDown) return;
+
+ // While lent to a fly animation the borrower owns the session's
+ // run state; ignore tile-visibility flips (the switch sets the tile
+ // invisible mid-flight and would otherwise pause the live frame).
+ if (_borrowed) return;
+ 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)
+ {
+ s_live.Remove(this);
+
+ // ShutdownAll already disposed every session; the rest of this teardown
+ // is WinRT interop that is illegal inside the WM_CLOSE cascade.
+ if (s_shuttingDown) return;
+
+ 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()
+ {
+ // Never resurrect a session once shutdown has begun — covers the
+ // TargetClosed / DeviceLost dispatcher callbacks that can land late.
+ if (s_shuttingDown) return;
+ 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 || _borrowed) 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);
+ // Base height feeds the rotation solve, so a size change invalidates it too.
+ _lastAppliedRotationDegrees = double.NaN;
+ _session.SetVisualSize(
+ new Vector2((float)hwndW, (float)hwndH),
+ new Vector2((float)baseW, (float)baseH));
+ _session.SetPerspective((float)PerspectiveDepthPx);
+ }
+
+ private void ApplyCornerRadius()
+ {
+ if (_session is null || _borrowed) 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 || _borrowed) 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 dpi = VisualTreeHelper.GetDpi(this);
+ var translateXPx = MirrorTranslateX * dpi.DpiScaleX;
+ var translateYPx = MirrorTranslateY * dpi.DpiScaleY;
+
+ // Shear + sprite rotation are solved together from the edge-angle pair and
+ // the CURRENT base height — see SolveEdgeAngles for the derivation and for
+ // why the height has to feed back in on every size change.
+ var (shearDegrees, rotationDegrees) = SolveEdgeAngles(TopEdgeDegrees, BottomEdgeDegrees, _lastBasePixelHeight);
+
+ if (rotationDegrees != _lastAppliedRotationDegrees)
+ {
+ _lastAppliedRotationDegrees = rotationDegrees;
+ _session.SetSpriteRotationY((float)rotationDegrees);
+ }
+
+ var matrix = ComposeTransform(shearDegrees, MirrorScale, translateXPx, translateYPx, _lastHwndPixelWidth, _lastHwndPixelHeight);
+ if (matrix == _lastAppliedTransform) return;
+ _lastAppliedTransform = matrix;
+ _session.SetTransformMatrix(matrix);
+ }
+
+ ///
+ /// Splits a pair of requested edge angles into the two knobs that reproduce
+ /// them: the affine vertical shear carried on the content visual, and the
+ /// sprite's native Y-rotation that the root's perspective divide foreshortens
+ /// into the converging pair.
+ ///
+ /// Screen slope (y grows DOWN) of an edge is -tan(deg), because the DPs use
+ /// "positive = right end rises". Pushing the rect through sprite-rotation →
+ /// content-shear → perspective-divide gives, for shear slope T (M12) and
+ /// convergence Q = H*tan(theta)/(2*depth): slope(top) = T + Q,
+ /// slope(bottom) = T - Q. Width, hover scale and cursor pull all cancel out of
+ /// both, so the split is exact and stays exact while the tile is scaled/pulled.
+ ///
+ ///
+ /// Must be re-solved whenever the sprite's pixel HEIGHT changes. Inverting
+ /// Q for theta is what makes the look size- and DPI-independent; a rotation held
+ /// fixed while the sprite grows produces convergence proportional to H, and the
+ /// perspective divide (fixed vanishing distance)
+ /// then blows up with half-width. A tray-sized solve reused on a stage-sized
+ /// flying card sends one vertical edge past 1.8x — taller than the screen.
+ ///
+ ///
+ internal static (double ShearDegrees, double SpriteRotationDegrees) SolveEdgeAngles(
+ double topEdgeDegrees, double bottomEdgeDegrees, double baseHeightPx)
+ {
+ var slopeTop = -Math.Tan(topEdgeDegrees * Math.PI / 180.0);
+ var slopeBottom = -Math.Tan(bottomEdgeDegrees * Math.PI / 180.0);
+ var shearSlope = (slopeTop + slopeBottom) / 2.0;
+ var converge = (slopeTop - slopeBottom) / 2.0;
+
+ var shearDegrees = Math.Atan(shearSlope) * 180.0 / Math.PI;
+ // converge == 0 solves to 0 degrees, i.e. parallel edges, no divide.
+ var rotationDegrees = baseHeightPx > 0.0
+ ? Math.Atan(2.0 * PerspectiveDepthPx * converge / baseHeightPx) * 180.0 / Math.PI
+ : 0.0;
+
+ return (shearDegrees, rotationDegrees);
+ }
+
+ private void ApplyOpacity()
+ {
+ if (_session is null || _borrowed) return;
+ var op = (float)Math.Clamp(MirrorOpacity, 0.0, 1.0);
+ if (op == _lastAppliedOpacity) return;
+ _lastAppliedOpacity = op;
+ _session.SetOpacity(op);
+ }
+
+ internal static Matrix4x4 ComposeTransform(double angleDegrees, double scale, double translateXPx, double translateYPx, double pixelWidth, double pixelHeight)
+ {
+ var s = (float)scale;
+ var tx = (float)translateXPx;
+ var ty = (float)translateYPx;
+
+ // No identity early-out: the perspective below is applied to EVERY tile,
+ // so even a flat middle row (angle 0, scale 1, no pull) gets the trapezoid.
+ var cx = (float)(pixelWidth / 2.0);
+ var cy = (float)(pixelHeight / 2.0);
+
+ var inner = Matrix4x4.CreateScale(s, s, 1f);
+ if (angleDegrees != 0.0)
+ {
+ // 2D vertical shear, the component both edges share: y' = y + tan(t)*x.
+ // Sides stay vertical; +angle drops the right edge, -angle raises it
+ // (SCREEN convention, opposite of the Top/BottomEdgeDegrees DPs).
+ // Tiles pass the mean of their two edge slopes; the difference between
+ // the edges is carried by the sprite rotation, not by this matrix.
+ var rad = (float)(angleDegrees * Math.PI / 180.0);
+ var shear = Matrix4x4.Identity;
+ shear.M12 = (float)Math.Tan(rad);
+ inner = inner * shear;
+ }
+
+ // Perspective and Y-rotation are NOT in this matrix — they live on
+ // separate visuals in CaptureSession (pure perspective on the root,
+ // native rotation on the sprite; see SetPerspective / SetSpriteRotationY),
+ // because Composition only foreshortens when perspective sits alone on an
+ // ancestor of the rotated visual. This matrix is affine only: scale +
+ // per-row shear + cursor pull.
+
+ // Center → transform → re-center, then bias re-center by translate.
+ var t1 = Matrix4x4.CreateTranslation(-cx, -cy, 0);
+ var t2 = Matrix4x4.CreateTranslation(cx + tx, cy + ty, 0);
+ return t1 * inner * t2;
+ }
+
+ ///
+ /// The live capture session backing this tile, or null before it starts.
+ /// Exposed so the sidebar drag ghost can drive size/skew on the shared
+ /// visual while it is borrowed.
+ ///
+ internal CaptureSession? Session => _session;
+
+ ///
+ /// Lend the live composition visual to the drag ghost: detach it from this
+ /// tile's host and stop driving the session. Returns null (caller falls back
+ /// to a static placeholder) if no session is running yet.
+ ///
+ internal Windows.UI.Composition.Visual? BorrowRootVisual()
+ {
+ if (_session is null || _compositionHost is null) return null;
+ var visual = _session.RootVisual;
+ if (visual is null) return null;
+ _borrowed = true;
+ // Keep frames flowing even if the tile was paused (hidden) — the
+ // flying card must be live, not a frozen last frame.
+ _session.Resume();
+ _compositionHost.Root = null;
+ return visual;
+ }
+
+ ///
+ /// Reclaim the visual after the drag ends and re-apply this tile's own
+ /// size / corner / transform / opacity (the ghost left the shared session
+ /// sized and skewed for the drag, so force past the value-cache guards).
+ ///
+ internal void ReturnRootVisual()
+ {
+ _borrowed = false;
+ if (_teardownPending)
+ {
+ // Tile went away (or its window closed) while the visual was
+ // lent out — finish the deferred teardown now instead of
+ // reattaching. Also covers the unload-while-borrowed leak where
+ // the session would otherwise never be disposed.
+ TeardownSession();
+ // If the tile is back in the tray by now, restart clean.
+ if (IsLoaded && IsVisible && PreviewHandle != IntPtr.Zero)
+ StartCaptureIfReady();
+ return;
+ }
+ if (_compositionHost is null || _session is null) return;
+ _compositionHost.Root = _session.RootVisual;
+ _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);
+ _lastAppliedRotationDegrees = double.NaN;
+ _lastAppliedOpacity = float.NaN;
+ RecomputePixelSize();
+ ApplyCornerRadius();
+ ApplyTransform();
+ ApplyOpacity();
+ // Match the tile's own visibility: if it's hidden in the tray, the
+ // session should idle again now that the borrower is done.
+ if (!IsVisible) _session.Pause();
+ }
+
+ private void TeardownSession()
+ {
+ if (_session is null) return;
+ if (_borrowed)
+ {
+ _teardownPending = true;
+ Log.Info("COMPTHUMB", $"Teardown deferred (visual borrowed) for 0x{PreviewHandle:X}");
+ return;
+ }
+ _teardownPending = false;
+ _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);
+ _lastAppliedRotationDegrees = double.NaN;
+ _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/Controls/IconOverlayManager.cs b/StageManager/Controls/IconOverlayManager.cs
index 5aec203..edab3e4 100644
--- a/StageManager/Controls/IconOverlayManager.cs
+++ b/StageManager/Controls/IconOverlayManager.cs
@@ -5,11 +5,13 @@
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
+using System.Windows.Interop;
using System.Windows.Media.Animation;
using System.Windows.Media.Effects;
using StageManager.Animations;
using StageManager.Helpers;
using StageManager.Model;
+using StageManager.Native.PInvoke;
namespace StageManager.Controls
{
@@ -221,7 +223,7 @@ private void ApplyIconTarget(SceneModel scene, int idx, WindowModel window, stri
}
}
- private LiveIcon CreateLiveIcon(ImageSource source, string processKey, SceneModel scene, int idx, double initialScale)
+ private LiveIcon CreateLiveIcon(ImageSource? source, string processKey, SceneModel scene, int idx, double initialScale)
{
// Two scale transforms composed via TransformGroup: morph (layout) and hover (interaction).
// They animate independently on the same Image without stomping each other.
@@ -291,7 +293,7 @@ private void ApplyLabelTarget(SceneModel scene, double left, double top, double
Canvas.SetLeft(tb, canvasLeft);
Canvas.SetTop(tb, canvasTop);
_labels[scene.Id] = new LiveLabel { Text = tb };
- _overlay.Canvas.Children.Add(tb);
+ _overlay!.Canvas.Children.Add(tb);
AnimateDouble(tb, UIElement.OpacityProperty, 1.0);
}
}
@@ -377,6 +379,20 @@ public void SlideOut(double offsetX, TimeSpan duration, IEasingFunction easing)
transform.BeginAnimation(TranslateTransform.XProperty, anim, HandoffBehavior.SnapshotAndReplace);
}
+ // Re-asserts HWND_TOPMOST so the icon overlay sits above sibling
+ // topmost windows. Needed after MainWindow flips Topmost = true on
+ // unstow, which otherwise pushes the sidebar above the icons.
+ public void BringToFront()
+ {
+ if (_overlay == null) return;
+ var hwnd = new WindowInteropHelper(_overlay).Handle;
+ if (hwnd == IntPtr.Zero) return;
+ Win32.SetWindowPos(hwnd, Win32.HWND_TOPMOST, 0, 0, 0, 0,
+ Win32.SetWindowPosFlags.IgnoreMove |
+ Win32.SetWindowPosFlags.IgnoreResize |
+ Win32.SetWindowPosFlags.DoNotActivate);
+ }
+
private TranslateTransform GetCanvasSlideTransform()
{
if (_overlay!.Canvas.RenderTransform is TranslateTransform existing)
diff --git a/StageManager/Converters/IndexToOffsetMarginConverter.cs b/StageManager/Converters/IndexToOffsetMarginConverter.cs
index 3c89786..a7be391 100644
--- a/StageManager/Converters/IndexToOffsetMarginConverter.cs
+++ b/StageManager/Converters/IndexToOffsetMarginConverter.cs
@@ -11,7 +11,7 @@ namespace StageManager.Converters
///
public sealed class IndexToOffsetMarginConverter : IValueConverter
{
- private const double Step = 6; // distance in pixels per index step
+ private const double Step = 10; // distance in pixels per index step
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
diff --git a/StageManager/Log.cs b/StageManager/Log.cs
index abf2590..73df285 100644
--- a/StageManager/Log.cs
+++ b/StageManager/Log.cs
@@ -86,8 +86,13 @@ public static void Info(string tag, string message, IntPtr handle)
}
[Conditional("DEBUG")]
- public static void Window(string tag, string action, Native.Window.IWindow window)
+ public static void Window(string tag, string action, Native.Window.IWindow? window)
{
+ if (window is null)
+ {
+ Debug.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] [{tag}] {action}: (null)");
+ return;
+ }
Debug.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] [{tag}] {action}: '{window.Title}' Handle=0x{window.Handle:X} Process='{window.ProcessFileName}' Minimized={window.IsMinimized} Focused={window.IsFocused}");
}
diff --git a/StageManager/MainWindow.xaml b/StageManager/MainWindow.xaml
index 48899a5..f48a706 100644
--- a/StageManager/MainWindow.xaml
+++ b/StageManager/MainWindow.xaml
@@ -16,7 +16,7 @@
WindowStyle="None"
Title="MainWindow"
Height="450"
- Width="180"
+ Width="240"
ShowActivated="False"
Background="Transparent"
Name="thisWindow">
@@ -24,28 +24,29 @@