DEV Community

Lumibear Studio
Lumibear Studio

Posted on

Implementing Chrome-Style Tab Tear-off in WinUI 3

Implementing Chrome-Style Tab Tear-off in WinUI 3

A deep dive into the pitfalls of implementing tab tear-off and re-docking in a WinUI 3 desktop app —
why SC_DRAGMOVE doesn't work, how to use DWM Cloak to create windows without flicker,
ghost tab indicators, and translucent window feedback.


The End Result

Drag a tab outside the window and a new window spawns, following your cursor. Hover over another window's tab bar and a ghost tab appears, while the dragged window turns translucent. Drop it and the tab merges at the exact position. Even single-tab windows can be dragged and merged into other windows.

It's that feature you use every day in Chrome.

Here's a rundown of the problems I hit while implementing this in WinUI 3 + C#, and how I solved them.


1. The Big Picture — Five Stages

[PointerPressed]  →  [PointerMoved]  →  [Mouse Released]
 Detect drag start     Outside window → TearOff     Re-dock or drop
                       Inside window → Tab reorder
Enter fullscreen mode Exit fullscreen mode
Stage Core Problem Solution
1. Drag detection Click vs. drag 8px Euclidean distance threshold
2. New window creation Flicker on Activate DWM Cloak + timer-based uncloaking
3. Cursor-tracking drag SC_DRAGMOVE won't work DispatcherTimer(8ms) + GetAsyncKeyState
4. Re-docking feedback Visualize where the tab will land Ghost tab gap + translucent window
5. Re-docking execution Transfer tab state + precise insertion TabStateDto + GetInsertIndexFromScreen

2. Tab State Serialization — How Do You Pass a Tab Between Windows?

To move a tab torn from Window A into Window B, you need to extract the tab's state into a serializable form.

public record TabStateDto(
    string Id,
    string Header,
    string Path,
    int ViewMode,
    int IconSize
);
Enter fullscreen mode Exit fullscreen mode

Why a record?

  • ExplorerViewModel holds COM handles, thumbnail caches, FileSystemWatcher instances — none of which are serializable
  • TabStateDto carries only the path and view mode, so the new window can recreate the ViewModel from scratch
  • The same DTO is reused for session save/restore and workspace persistence
var dto = new TabStateDto(
    tab.Id, tab.Header, tab.Path,
    (int)tab.ViewMode, (int)tab.IconSize);
Enter fullscreen mode Exit fullscreen mode

3. Drag Detection — Click or Drag?

Clicking a tab switches to it; dragging tears it off. We need to distinguish between the two.

private bool _isTabDragging;
private Point _tabDragStartPoint;
private TabItem? _draggingTab;
private const double TAB_DRAG_THRESHOLD = 8; // 8 DIP

private void OnTabItemPointerPressed(object sender, PointerRoutedEventArgs e)
{
    _tabDragStartPoint = e.GetCurrentPoint(null).Position;
    _draggingTab = tab;
    _isTabDragging = false;

    // Capture pointer — receive PointerMoved even outside the tab area
    fe.CapturePointer(e.Pointer);
}
Enter fullscreen mode Exit fullscreen mode
private void OnTabItemPointerMoved(object sender, PointerRoutedEventArgs e)
{
    if (_draggingTab == null) return;

    var currentPoint = e.GetCurrentPoint(null).Position;
    double dx = currentPoint.X - _tabDragStartPoint.X;
    double dy = currentPoint.Y - _tabDragStartPoint.Y;

    if (!_isTabDragging)
    {
        if (Math.Sqrt(dx * dx + dy * dy) < TAB_DRAG_THRESHOLD)
            return;
        _isTabDragging = true;

        // Only show drag visual feedback when there are multiple tabs
        if (ViewModel.Tabs.Count > 1)
        {
            ApplyDragVisual(sender as FrameworkElement, true);
            ExpandTitleBarPassthrough();
        }
    }

    // Branch based on tab count
    // - 1 tab: drag the entire window (see section below)
    // - 2+ tabs: tear off if outside window, reorder if inside
}
Enter fullscreen mode Exit fullscreen mode

Drag Visual Feedback — Showing "You're Dragging"

When the drag starts, the tab lifts slightly and becomes translucent. As the cursor approaches the window edge, it fades further — hinting that it's about to detach.

// Drag start: semi-transparent + lifted 3px upward
private void ApplyDragVisual(FrameworkElement? tabElement, bool isDragging)
{
    if (isDragging)
    {
        tabElement.Opacity = 0.6;
        tabElement.Translation = new Vector3(0, -3, 0);
    }
    else
    {
        tabElement.Opacity = 1.0;
        tabElement.Translation = new Vector3(0, 0, 0);
    }
}

// Near edge: fade from 0.6 → 0.3 + lift from -3 → -6
private void UpdateTearOffProximityVisual(FrameworkElement? tabElement)
{
    double scale = AppTitleBar?.XamlRoot?.RasterizationScale ?? 1.0;
    int edgeThreshold = (int)(30 * scale); // 30 DIP

    // Minimum distance to window edge (physical pixels)
    int minDist = Math.Min(
        Math.Min(cursorPos.X - rect.Left, rect.Right - cursorPos.X),
        Math.Min(cursorPos.Y - rect.Top, rect.Bottom - cursorPos.Y));

    if (minDist < edgeThreshold)
    {
        double ratio = Math.Max(0, (double)minDist / edgeThreshold);
        tabElement.Opacity = 0.3 + 0.3 * ratio;       // 0.6 → 0.3
        float lift = -3f - 3f * (1f - (float)ratio);   // -3 → -6
        tabElement.Translation = new Vector3(0, lift, 0);
    }
}
Enter fullscreen mode Exit fullscreen mode

Detecting When the Cursor Leaves the Window — Direct Win32 API Calls

WinUI 3's XAML events don't accurately report pointer positions outside the window.

private bool IsCursorOutsideWindow()
{
    if (!NativeMethods.GetCursorPos(out var cursorPos)) return false;
    if (!NativeMethods.GetWindowRect(_hwnd, out var windowRect)) return false;

    return cursorPos.X < windowRect.Left ||
           cursorPos.X > windowRect.Right ||
           cursorPos.Y < windowRect.Top ||
           cursorPos.Y > windowRect.Bottom;
}
Enter fullscreen mode Exit fullscreen mode

Note: Both GetWindowRect and GetCursorPos return physical pixels, so no DPI conversion is needed.


4. DWM Cloak — Eliminating New Window Flicker

This is the heart of the article.

The Problem: WinUI 3 Windows Need Activate() to Render XAML

var newWindow = new MainWindow();
newWindow.Activate(); // ← A white window flashes on screen at this moment
Enter fullscreen mode Exit fullscreen mode

Just calling new MainWindow() doesn't start the XAML pipeline. You have to call Activate() to kick off layout and rendering — but the instant you do, a blank white window flashes at the default position.

Attempt 1: SetWindowPos Before Activate → Failed

SetWindowPos(hwnd, ..., cursorX, cursorY, ...);
newWindow.Activate(); // ← Activate overwrites the position with defaults
Enter fullscreen mode Exit fullscreen mode

Attempt 2: SetWindowPos Immediately After Activate → Flicker

newWindow.Activate();
SetWindowPos(hwnd, ..., cursorX, cursorY, ...); // ← Already shown for one frame
Enter fullscreen mode Exit fullscreen mode

The Solution: DWM Cloak

DWM (Desktop Window Manager) Cloak hides a window from the DWM composition layer. The window exists and XAML renders normally, but it's not drawn on screen. Originally designed to hide inactive windows during virtual desktop switches, we repurpose it to prevent initialization flicker.

private void TearOffTab(TabItem tab)
{
    // 1. Capture source window size + cursor position
    NativeMethods.GetWindowRect(_hwnd, out var srcRect);
    int srcW = srcRect.Right - srcRect.Left;
    int srcH = srcRect.Bottom - srcRect.Top;
    NativeMethods.GetCursorPos(out var cursorPos);

    // 2. Remove tab from source window (4 panel types + ViewModel)
    RemoveMillerPanel(tab.Id);
    RemoveDetailsPanel(tab.Id);
    RemoveListPanel(tab.Id);
    RemoveIconPanel(tab.Id);
    ViewModel.CloseTab(index);

    // 3. Create new window + obtain HWND (not yet activated)
    var newWindow = new MainWindow();
    newWindow._pendingTearOff = dto;  // Tab state consumed in Loaded
    var newHwnd = WindowNative.GetWindowHandle(newWindow);

    // 4. ★ Cloaking ON — hidden from screen
    int cloakOn = 1;
    NativeMethods.DwmSetWindowAttribute(
        newHwnd, DWMWA_CLOAK, ref cloakOn, sizeof(int));

    // Disable animations too (prevents additional flicker from transitions)
    int transOff = 1;
    NativeMethods.DwmSetWindowAttribute(
        newHwnd, DWMWA_TRANSITIONS_FORCEDISABLED, ref transOff, sizeof(int));

    // 5. Activate — XAML rendering starts (but invisible thanks to cloaking!)
    App.Current.RegisterWindow(newWindow);
    newWindow.Activate();

    // 6. Position at cursor with same size as source window
    int offsetX = srcW / 4;  // Cursor at 25% of the title bar
    int offsetY = 15;
    NativeMethods.SetWindowPos(newHwnd, HWND_TOP,
        cursorPos.X - offsetX, cursorPos.Y - offsetY,
        srcW, srcH, SWP_NOACTIVATE);

    // 7. Start manual drag (timer will uncloak)
    StartManualWindowDrag(newHwnd, offsetX, offsetY, srcW, srcH);
}
Enter fullscreen mode Exit fullscreen mode
[Cloak ON] → Activate → XAML renders (invisible) → SetWindowPos → [Uncloak after 5 frames]
                                                                          ↑
                                                                  First frame user sees
Enter fullscreen mode Exit fullscreen mode

5. Manual Window Drag — The Absence of SC_DRAGMOVE

The Problem: SC_DRAGMOVE Doesn't Work in WinUI 3

In classic Win32, you could start an OS-native drag with SendMessage(hwnd, WM_SYSCOMMAND, SC_MOVE, 0). But WinUI 3's IXP (Input Cross-Process) layer filters NC messages. Sending SC_DRAGMOVE results in nothing happening.

The Solution: DispatcherTimer + GetAsyncKeyState

Build the drag loop yourself. Poll the cursor position at 8ms intervals (~120Hz) and move the window with SetWindowPos.

private void StartManualWindowDrag(IntPtr targetHwnd,
    int dragOffsetX, int dragOffsetY,
    int targetWidth, int targetHeight)
{
    var dragTimer = new DispatcherTimer();
    dragTimer.Interval = TimeSpan.FromMilliseconds(8); // ~120Hz

    bool uncloaked = false;
    bool sizeApplied = false;
    int frameCount = 0;
    MainWindow? lastGhostTarget = null;

    dragTimer.Tick += (s, e) =>
    {
        // ★ Read mouse button state directly from hardware
        bool mouseDown = (NativeMethods.GetAsyncKeyState(VK_LBUTTON)
                          & 0x8000) != 0;

        if (!mouseDown)
        {
            dragTimer.Stop();
            HandleDrop(targetHwnd);  // Finalize re-dock or drop
            return;
        }

        NativeMethods.GetCursorPos(out var pos);
        frameCount++;

        // First 3 frames: SetWindowPos with size included
        // (Activate's async layout may reset the window size to defaults)
        if (!sizeApplied && frameCount <= 3)
        {
            NativeMethods.SetWindowPos(targetHwnd, HWND_TOP,
                pos.X - dragOffsetX, pos.Y - dragOffsetY,
                targetWidth, targetHeight, SWP_NOACTIVATE);

            if (frameCount == 3) sizeApplied = true;
        }
        else
        {
            // After that: move position only (SWP_NOSIZE)
            NativeMethods.SetWindowPos(targetHwnd, HWND_TOP,
                pos.X - dragOffsetX, pos.Y - dragOffsetY,
                0, 0, SWP_NOSIZE | SWP_NOACTIVATE);
        }

        // ~40ms (5 frames) later: uncloak
        if (!uncloaked && frameCount >= 5)
        {
            uncloaked = true;
            int cloakOff = 0;
            NativeMethods.DwmSetWindowAttribute(
                targetHwnd, DWMWA_CLOAK, ref cloakOff, sizeof(int));
            NativeMethods.SetForegroundWindow(targetHwnd);
        }

        // After 30 frames: detect ghost tab hover (see section below)
        if (uncloaked && frameCount >= 30 && frameCount % 4 == 0)
            DetectGhostTabHover(targetHwnd, pos, ref lastGhostTarget);
    };

    dragTimer.Start();
}
Enter fullscreen mode Exit fullscreen mode

Why GetAsyncKeyState?

  • GetKeyState() is message queue-based — if messages aren't processed, it returns stale state
  • GetAsyncKeyState() is hardware-based — it reads the current physical state directly
  • Since WinUI 3 swallows NC messages, the message queue can't be trusted

Why Set the Size for the First 3 Frames?

After Activate(), WinUI 3 may asynchronously perform layout and reset the window size to defaults. Setting it once isn't enough — you need to repeat it for 3 frames (~24ms) to be safe.

Why Wait 30 Frames Before Detecting Re-docking?

At the moment of tear-off, the cursor is still near the source window. If you allow re-docking immediately, the tab snaps right back the instant it detaches. A 30-frame (~240ms) grace period gives the user time to move intentionally.


6. Ghost Tab — "This Is Where It'll Go" Visual Feedback

In the earlier version, we could only determine whether re-docking was possible, not where the tab would land. Like Chrome, we added a ghost tab that opens a gap at the insertion point.

XAML: Ghost Indicator Overlay

<Border x:Name="GhostTabIndicator" Visibility="Collapsed"
        Height="34" VerticalAlignment="Bottom"
        HorizontalAlignment="Left"
        Background="{ThemeResource SpanAccentBrush}" Opacity="0.15"
        BorderBrush="{ThemeResource SpanAccentBrush}" BorderThickness="1.5"
        CornerRadius="6,6,0,0" IsHitTestVisible="False"/>
Enter fullscreen mode Exit fullscreen mode

IsHitTestVisible="False" is critical. Without it, the ghost swallows pointer events and breaks the drag.

Showing and Hiding the Ghost Tab

Every 4 frames (~32ms) in the drag timer, we call FindWindowAtPoint. If the cursor is over another window's tab bar, we call that window's ShowGhostTab.

// Inside the drag timer — hover detection every 4 frames
var hoverTarget = App.Current.FindWindowAtPoint(
    pos.X, pos.Y, draggedWindow);  // Exclude self

if (hoverTarget != lastGhostTarget)
{
    // Hide ghost on previous target
    if (lastGhostTarget != null)
        lastGhostTarget.DispatcherQueue.TryEnqueue(
            () => lastGhostTarget.HideGhostTab());

    lastGhostTarget = hoverTarget;

    // ★ Change opacity of dragged window — docking hint
    SetWindowOpacity(targetHwnd, hoverTarget != null ? (byte)180 : (byte)255);

    // Show ghost on new target
    if (hoverTarget != null)
        hoverTarget.DispatcherQueue.TryEnqueue(
            () => hoverTarget.ShowGhostTab(pos.X, pos.Y));
}
Enter fullscreen mode Exit fullscreen mode

ShowGhostTab — Creating the Gap

Calculate the insertion index from screen coordinates, then add Margin.Left to the tab at that position to create a gap.

public void ShowGhostTab(int screenX, int screenY)
{
    int insertIndex = GetInsertIndexFromScreen(screenX);
    if (insertIndex == _ghostTabIndex) return; // No change

    ClearTabMargins(); // Remove previous gap
    _ghostTabIndex = insertIndex;

    // Determine gap size based on expected tab width after docking
    double ghostWidth = CalculateTabWidthForCount(tabCount + 1);

    // Add left margin to the tab at this position → existing tabs shift over, creating a gap
    if (insertIndex < tabCount)
    {
        var elem = TabRepeater.TryGetElement(insertIndex) as FrameworkElement;
        elem.Margin = new Thickness(ghostWidth, 0, 0, 0);
    }

    // Show translucent indicator
    GhostTabIndicator.Width = ghostWidth;
    GhostTabIndicator.Margin = new Thickness(insertIndex * _calculatedTabWidth, 0, 0, 0);
    GhostTabIndicator.Visibility = Visibility.Visible;
}
Enter fullscreen mode Exit fullscreen mode

GetInsertIndexFromScreen — Screen Coordinates to Tab Index

private int GetInsertIndexFromScreen(int screenX)
{
    // 1. Screen coordinates → client coordinates (physical pixels)
    int clientX = screenX - windowRect.Left;

    // 2. Physical pixels → DIP conversion
    double scale = AppTitleBar?.XamlRoot?.RasterizationScale ?? 1.0;
    double dipX = clientX / scale;

    // 3. Relative coordinates from TabRepeater origin
    var origin = TabRepeater.TransformToVisual(null).TransformPoint(new Point(0, 0));
    double relativeX = dipX - origin.X;

    // 4. Divide by tab width to get index
    int index = (int)(relativeX / _calculatedTabWidth);
    return Math.Clamp(index, 0, tabCount); // Allow up to tabCount (append at end)
}
Enter fullscreen mode Exit fullscreen mode

Translucent Window Feedback — WS_EX_LAYERED

When the dragged window is over another window's tab bar, it becomes translucent (alpha 180). Otherwise, it returns to opaque (alpha 255).

internal static void SetWindowOpacity(IntPtr hwnd, byte alpha)
{
    int exStyle = GetWindowLong(hwnd, GWL_EXSTYLE);
    if (alpha < 255)
    {
        // Add WS_EX_LAYERED + set opacity
        if ((exStyle & WS_EX_LAYERED) == 0)
            SetWindowLong(hwnd, GWL_EXSTYLE, exStyle | WS_EX_LAYERED);
        SetLayeredWindowAttributes(hwnd, 0, alpha, LWA_ALPHA);
    }
    else
    {
        // Remove WS_EX_LAYERED (avoid compositing overhead)
        if ((exStyle & WS_EX_LAYERED) != 0)
            SetWindowLong(hwnd, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED);
    }
}
Enter fullscreen mode Exit fullscreen mode

The key detail: removing WS_EX_LAYERED when alpha is 255. Leaving it on incurs ongoing DWM compositing overhead.


7. Single-Tab Windows Can Be Dragged Too

If tear-off only worked with 2+ tabs, you'd never be able to re-dock a single-tab window that was already torn off. So when there's only one tab, we drag the entire window while detecting re-docking opportunities.

// Inside OnTabItemPointerMoved — branch for single tab
if (ViewModel.Tabs.Count <= 1)
{
    NativeMethods.GetCursorPos(out var curPos);
    if (!_isWindowDragging)
    {
        _isWindowDragging = true;
        _windowDragFrameCount = 0;
        _windowDragStartCursor = curPos;
        NativeMethods.GetWindowRect(_hwnd, out _windowDragStartRect);
    }

    // Move the entire window by cursor delta
    int newX = _windowDragStartRect.Left + (curPos.X - _windowDragStartCursor.X);
    int newY = _windowDragStartRect.Top + (curPos.Y - _windowDragStartCursor.Y);
    NativeMethods.SetWindowPos(_hwnd, IntPtr.Zero,
        newX, newY, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);

    // After 30 frames, check every 4 frames if hovering over another window
    _windowDragFrameCount++;
    if (_windowDragFrameCount >= 30 && _windowDragFrameCount % 4 == 0)
    {
        var hoverTarget = App.Current.FindWindowAtPoint(curPos.X, curPos.Y, this);
        // ... show/hide ghost tab + toggle translucency (same as above)
    }
    return;
}
Enter fullscreen mode Exit fullscreen mode

When the mouse is released (OnTabItemPointerReleased), FindWindowAtPoint locates the target window and DockTab merges the tab. The current window is then closed.


8. Re-docking — FindWindowAtPoint

Determining the Drop Target

Iterate over all windows the app manages, looking for one where the cursor falls within the tab bar region (top 48px).

// App.xaml.cs
public MainWindow? FindWindowAtPoint(int screenX, int screenY, Window exclude)
{
    lock (_windowLock)
    {
        foreach (var w in _windows)
        {
            if (w == exclude || w is not MainWindow mw) continue;

            var hwnd = WindowNative.GetWindowHandle(w);
            if (NativeMethods.GetWindowRect(hwnd, out var rect))
            {
                // DPI-corrected — 40 DIP + 8 DIP margin
                uint dpi = NativeMethods.GetDpiForWindow(hwnd);
                int tabBarHeight = (int)(48.0 * dpi / 96.0);

                if (screenX >= rect.Left && screenX <= rect.Right &&
                    screenY >= rect.Top && screenY <= rect.Top + tabBarHeight)
                {
                    return mw;
                }
            }
        }
    }
    return null;
}
Enter fullscreen mode Exit fullscreen mode

DockTab — Accepting a Tab

Use the ghost tab's index directly for precise insertion.

public void DockTab(TabStateDto dto, int insertIndex = -1)
{
    HideGhostTab();

    var explorer = new ExplorerViewModel(root, fileService);
    var newTab = new TabItem
    {
        Header = dto.Header,
        Path = dto.Path,
        ViewMode = (ViewMode)dto.ViewMode,
        IconSize = (ViewMode)dto.IconSize,
        Explorer = explorer
    };

    if (!string.IsNullOrEmpty(dto.Path))
        _ = explorer.NavigateToPath(dto.Path);

    // Insert at exact position, or append at end
    if (insertIndex >= 0 && insertIndex < ViewModel.Tabs.Count)
        ViewModel.Tabs.Insert(insertIndex, newTab);
    else
        ViewModel.Tabs.Add(newTab);

    // Create + switch 4 panel types (Miller/Details/List/Icon)
    CreateMillerPanelForTab(newTab);
    SwitchMillerPanel(newTab.Id);
    SwitchDetailsPanel(newTab.Id, newTab.ViewMode == ViewMode.Details);
    SwitchListPanel(newTab.Id, newTab.ViewMode == ViewMode.List);
    SwitchIconPanel(newTab.Id, IsIconMode(newTab.ViewMode));
    ViewModel.SwitchToTab(insertIndex);
    ResubscribeLeftExplorer();
    UpdateViewModeVisibility();
    FocusActiveView();
}
Enter fullscreen mode Exit fullscreen mode

Cross-Window Thread Handling

In WinUI 3, each window has its own DispatcherQueue. To touch another window's UI, you must execute on that window's thread.

if (targetWindow == this)
{
    // Same window — call directly
    targetWindow.DockTab(dockDto, ghostIdx);
}
else
{
    // Different window — dispatch to its queue
    targetWindow.DispatcherQueue.TryEnqueue(() =>
    {
        targetWindow.DockTab(dockDto, ghostIdx);
    });
}
Enter fullscreen mode Exit fullscreen mode

9. Tab Reorder — Rearranging Tabs Within a Window

When the cursor stays inside the window, we reorder tabs instead of tearing off. Since we use Chrome-style fixed-width tabs (MIN 60px, MAX 200px), dividing the cursor's X coordinate by the tab width gives us the index directly.

// When cursor is inside the window
var tabIndex = GetTabIndexAtPoint(currentPoint);
if (currentIndex != tabIndex)
{
    ViewModel.Tabs.Move(currentIndex, tabIndex);
    ViewModel.ActiveTabIndex = tabIndex;

    // ★ After Move, ItemsRepeater repositions elements and releases pointer capture.
    // Re-capture on the element at the new position (otherwise system title bar drag kicks in)
    if (TabRepeater.TryGetElement(tabIndex) is UIElement newElem)
        newElem.CapturePointer(e.Pointer);
}
Enter fullscreen mode Exit fullscreen mode

10. Multi-Window Lifecycle

// App.xaml.cs
private readonly List<Window> _windows = new();
private readonly object _windowLock = new();

public void RegisterWindow(Window w) { lock (_windowLock) _windows.Add(w); }
public void UnregisterWindow(Window w) { lock (_windowLock) _windows.Remove(w); }

// Last window closed → exit app
// WinUI 3's normal teardown sometimes hangs, so we use Process.Kill()
Enter fullscreen mode Exit fullscreen mode
  • _forceClose flag: close immediately without "Save changes?" dialog during re-docking
  • _isTearOffWindow flag: torn-off windows are excluded from session save
  • GetRegisteredWindows(): returns a snapshot copy (safe to iterate while the list changes)

11. Full Sequence Diagram

User: starts dragging a tab
    │
    ├─ OnTabItemPointerPressed()
    │   ├─ Record drag start point + capture pointer
    │   └─ Switch to tab
    │
    ├─ OnTabItemPointerMoved() (repeating)
    │   ├─ Under 8 DIP threshold → return
    │   ├─ Over threshold → _isTabDragging = true
    │   │
    │   ├─ [1 tab] Drag entire window + ghost tab detection after 30 frames
    │   │
    │   └─ [2+ tabs]
    │       ├─ ApplyDragVisual (semi-transparent + lift)
    │       ├─ UpdateTearOffProximityVisual (edge proximity fade)
    │       ├─ Inside window → GetTabIndexAtPoint → Tabs.Move (reorder)
    │       └─ Outside window → TearOffTab()
    │
    ├─ TearOffTab()
    │   ├─ Create TabStateDto
    │   ├─ Remove 4 panel types + ViewModel.CloseTab
    │   ├─ new MainWindow(_pendingTearOff = dto)
    │   ├─ ★ DWMWA_CLOAK = 1 (hide)
    │   ├─ Activate (XAML starts, not visible)
    │   ├─ SetWindowPos (at cursor)
    │   └─ StartManualWindowDrag()
    │
    ├─ DispatcherTimer (8ms ≈ 120Hz)
    │   ├─ GetAsyncKeyState(VK_LBUTTON) — hardware state
    │   ├─ Frames 1–3: SetWindowPos(size+position) — prevent Activate overwrite
    │   ├─ Frame 5: ★ DWMWA_CLOAK = 0 (reveal!)
    │   ├─ Frames 6–29: SetWindowPos(position only)
    │   └─ Frame 30+: ghost tab detection every 4 frames
    │       ├─ FindWindowAtPoint (DPI-corrected, 48px tab bar)
    │       ├─ ShowGhostTab → gap margin + indicator
    │       └─ SetWindowOpacity(180) translucent transition
    │
User: releases mouse
    │
    ├─ frameCount < 30 → finalize as independent window
    └─ frameCount >= 30 → FindWindowAtPoint()
        ├─ No target → finalize as independent window + uncloak
        └─ Target found → DockTab(dto, ghostIdx)
            ├─ HideGhostTab
            ├─ Create new TabItem + ExplorerViewModel
            ├─ Tabs.Insert(ghostIdx) — exact position
            ├─ Create + switch 4 panel types
            ├─ Close torn-off window (_forceClose)
            └─ Done
Enter fullscreen mode Exit fullscreen mode

12. Pitfall Notes — Traps I Fell Into

Pitfall 1: Activate() Overwrites SetWindowPos

WinUI 3's Activate asynchronously resets window size. Setting it once isn't enough — you need to repeat for 3 frames to be safe.

Pitfall 2: Don't Use SC_DRAGMOVE

WinUI 3's IXP layer swallows NC messages. WM_NCLBUTTONDOWN + SC_MOVE does absolutely nothing.

Pitfall 3: Don't Mix SetTitleBar with WM_NCHITTEST

When customizing the title bar, use only SetTitleBar() + SetRegionRects(Passthrough). Mixing in a WM_NCHITTEST override conflicts with the IXP layer. Don't call SetRegionRects(Caption) either — it overwrites the system caption button (minimize/maximize/close) regions.

Pitfall 4: GetKeyState vs. GetAsyncKeyState

GetKeyState is message queue-based, so when WinUI 3 is swallowing messages, it reports a released mouse button as still pressed. Always use GetAsyncKeyState.

Pitfall 5: Re-docking onto Yourself

When a torn-off window is dropped near its original position, FindWindowAtPoint finds itself. Always exclude self with the exclude parameter.

Pitfall 6: Preventing Instant Re-docking

Right after tear-off, the cursor is still near the source window. If you allow immediate re-docking, the tab snaps back the instant it detaches. A 30-frame (~240ms) grace period is essential.

Pitfall 7: Pointer Capture Lost After Tab Reorder

When Tabs.Move() is called on an ItemsRepeater, elements get repositioned and pointer capture is released. If you don't recapture, the system title bar drag kicks in and the window starts moving.

Pitfall 8: Cross-Window Threading

WinUI 3 gives each window its own DispatcherQueue. Forgetting DispatcherQueue.TryEnqueue when touching another window's UI results in a crash.

Pitfall 9: WS_EX_LAYERED Overhead

When removing translucency, calling SetLayeredWindowAttributes(255) alone isn't enough. You must remove the WS_EX_LAYERED style itself, or DWM compositing overhead persists.


13. Summary (TL;DR)

Problem Solution
Click vs. drag Math.Sqrt(dx² + dy²) >= 8 DIP
Window creation flicker DWMWA_CLOAK ON → Activate → OFF after 5 frames
SC_DRAGMOVE unavailable DispatcherTimer(8ms) + GetAsyncKeyState + SetWindowPos
Activate overwrites size Repeat SetWindowPos for 3 frames
Tab state transfer record TabStateDto (lightweight DTO)
Drop target detection GetWindowRect + DPI-corrected 48px tab bar hit test
Insertion point visualization Ghost tab gap (Margin.Left) + translucent indicator
Docking hint WS_EX_LAYERED + SetLayeredWindowAttributes(alpha 180)
Tear-off proximity hint Opacity 0.6→0.3 + lift -3→-6 within 30 DIP of edge
Prevent instant re-dock 30-frame (~240ms) grace period
Merging single-tab windows Drag entire window + FindWindowAtPoint
Tab reorder GetTabIndexAtPoint + Tabs.Move + re-capture pointer
Cross-window calls DispatcherQueue.TryEnqueue

Closing Thoughts

Chrome's tab tear-off is one of those features users take for granted — but implementing it means getting your hands dirty with OS-level window management, DWM composition, message queues, DPI coordinate systems, and cross-thread communication. It's a deep dive into desktop programming fundamentals.

WinUI 3 in particular is a hybrid that layers XAML + IXP on top of Win32, so many things that worked in pure Win32 (SC_DRAGMOVE, WM_NCHITTEST) simply don't. Bridging those gaps is both the real challenge and the (questionable) joy of WinUI 3 development.

This implementation is based on real code from SPAN Finder — a Miller Columns file explorer.


Questions or feedback? Drop a comment below.

Top comments (0)