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 ![Stage Manager](media/current_state.gif) -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 @@