Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7b8dade
feat: replace DwmThumbnail with WGC+Composition pipeline
depoledna May 14, 2026
c18b66f
chore: eliminate nullable-reference warnings
depoledna May 14, 2026
600c235
feat: bigger sidebar thumbs + fix UWP Settings icon
depoledna Jun 5, 2026
5623e25
feat: cursor-magnet hover pull on sidebar scenes
depoledna Jun 5, 2026
a8d5bfc
fix: sidebar stow slide + icons-above-scenes z-order
depoledna Jun 5, 2026
60cccf4
feat: 3D tilt on tray thumbnails; drag + crash fixes
depoledna Jun 6, 2026
05b798e
feat: live-frame drag ghost + scene-switch fly; drag-cancel + stow fixes
depoledna Jun 6, 2026
282c670
feat: live preview ghost for stage->tray drag
depoledna Jun 7, 2026
8510099
feat: tray thumbnail tilt as vertical shear
depoledna Jun 8, 2026
327d9ba
fix: keep windows on-screen (physical-px park + clamp new windows ins…
depoledna Jun 8, 2026
7c59536
wip: 3D resting look for tray thumbnails
depoledna Jun 13, 2026
c2e5fe8
feat(thumbnails): size tray cards by the macOS uniform-scale law
depoledna Aug 5, 2026
c77e70e
feat(thumbnails): pin both card edges with a two-angle trapezoid law
depoledna Aug 5, 2026
f417052
fix(capture): dispose sessions in OnClosing, before the WM_CLOSE cascade
depoledna Aug 5, 2026
bb3ad18
fix(sidebar): clamp stale index when reordering scenes on selection c…
depoledna Aug 5, 2026
5db61eb
fix(animation): re-solve flying card trapezoid against its live size
depoledna Aug 5, 2026
28d65ee
fix(sidebar): left-align window thumbnails in scene tiles
depoledna Aug 5, 2026
c77517c
docs(readme): update requirements and roadmap for the new preview stack
depoledna Aug 5, 2026
9029590
docs: drop citations of specs that are not in the repo
depoledna Aug 5, 2026
4029657
docs(codemaps): add architecture, rendering, windowing and data maps
depoledna Aug 5, 2026
08c29cb
docs: add a project overview to CLAUDE.md
depoledna Aug 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 24 additions & 24 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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)
Expand All @@ -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

Expand Down
11 changes: 6 additions & 5 deletions ReadMe.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -17,18 +17,19 @@ 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

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
Expand Down
43 changes: 43 additions & 0 deletions StageManager/Animations/BorderCard.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using StageManager.Helpers;

namespace StageManager.Animations
{
/// <summary>
/// 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.
/// </summary>
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);
}
}
2 changes: 1 addition & 1 deletion StageManager/Animations/DebugZoneOverlay.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ namespace StageManager.Animations
internal class DebugZoneOverlay
{
private readonly SceneTransitionAnimator _animator;
private List<Border> _zones;
private List<Border>? _zones;

public DebugZoneOverlay(SceneTransitionAnimator animator)
{
Expand Down
59 changes: 39 additions & 20 deletions StageManager/Animations/DragDropManager.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using StageManager.Animations;
using StageManager.Controls;
using StageManager.Native.PInvoke;
using StageManager.Native.Window;
using System;
Expand All @@ -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<Point> _getDpiScale;
private readonly Func<double> _getSidebarWidth;
private readonly Func<Rect> _getOverlayBounds;
private readonly Func<IWindow, Rect> _getWindowLogicalRect;
private readonly Func<IWindow, ImageSource> _getWindowIcon;
private readonly Func<IWindow, ImageSource?> _getWindowIcon;
private readonly Action _syncVisibility;
private readonly double _cornerRadius;

private int _stateValue = (int)DragState.None;
private DragState State
Expand All @@ -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<Point> getDpiScale,
Func<double> getSidebarWidth,
Func<Rect> getOverlayBounds,
Func<IWindow, Rect> getWindowLogicalRect,
Func<IWindow, ImageSource> getWindowIcon,
Action syncVisibility)
Func<IWindow, ImageSource?> 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;
Expand Down Expand Up @@ -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)
Expand All @@ -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);

Expand All @@ -137,7 +146,7 @@ await Dispatcher.CurrentDispatcher.InvokeAsync(() => { },
else
{
Log.Info("DRAG", "Dropped in buffer zone, cancelling");
_ghostWindow.Hide();
_ghost.Hide();
RestoreRealWindow(window);
}
}
Expand Down Expand Up @@ -174,7 +183,7 @@ private void StopPolling()
}
}

private void PollTick(object sender, EventArgs e)
private void PollTick(object? sender, EventArgs e)
{
if (_trackedWindow == null)
{
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -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()
Expand All @@ -253,7 +272,7 @@ private void Reset()
{
Win32.SetWindowStyleLongPtr(_trackedWindow.Handle, _originalStyle);
RestoreRealWindow(_trackedWindow);
_ghostWindow.Hide();
_ghost.Hide();
}
catch { }
}
Expand Down
8 changes: 4 additions & 4 deletions StageManager/Animations/DragGhostWindow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -65,12 +65,12 @@ private void RunOverlayThread()
/// <summary>
/// Shows the drag ghost at the given logical coordinates with the specified icon.
/// </summary>
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();
Expand Down
22 changes: 22 additions & 0 deletions StageManager/Animations/IFlyingCard.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System.Windows;

namespace StageManager.Animations
{
/// <summary>
/// A card that travels between the sidebar and the stage during a transition.
/// Backed either by the tray tile's live capture (<see cref="LiveCardHost"/>) or
/// a static icon placeholder (<see cref="BorderCard"/>). The driver (cursor drag
/// or timed fly) calls <see cref="Update"/> each frame with the current rect and
/// 3D tilt, then <see cref="Release"/> once when finished.
/// </summary>
internal interface IFlyingCard
{
/// <param name="baseRect">Card rect in logical screen units.</param>
/// <param name="skewDegrees">3D Y-tilt (tray angle in the sidebar, 0 flat on stage).</param>
void Update(Rect baseRect, double skewDegrees);

void SetVisible(bool visible);

void Release();
}
}
Loading