feat(ui): ghost-window drag infrastructure for task rows

Add the borderless, transparent, topmost, click-through DragGhostWindow that
hosts a tilted (~-6deg) translucent snapshot of the dragged row, a
TaskDragController that owns its lifecycle (snapshot -> show -> follow -> close),
and a pure DPI-aware DragHitTest helper (unit-tested) for the cross-window
screen hit test. Adds the TaskRowViewModel.IsDragging flag and the
'grabbed' Border.task-row.dragging style (lift + scale + lower opacity +
shadow). Not yet wired into the drag source.
This commit is contained in:
Mika Kuns
2026-06-25 22:39:17 +02:00
parent 946d26cc4b
commit 05aec8ebfa
8 changed files with 200 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="ClaudeDo.Ui.Views.Controls.DragGhostWindow"
SystemDecorations="None"
Background="Transparent"
TransparencyLevelHint="Transparent"
ShowInTaskbar="False"
ShowActivated="False"
Topmost="True"
Focusable="False"
IsHitTestVisible="False"
CanResize="False">
<!-- Translucent, slightly tilted snapshot of the dragged row that follows the cursor
across the whole screen (incl. over the separate Mission Control window). -->
<Image x:Name="GhostImage" Opacity="0.7"
HorizontalAlignment="Center" VerticalAlignment="Center"
RenderTransformOrigin="0.5,0.5">
<Image.RenderTransform>
<RotateTransform Angle="-6"/>
</Image.RenderTransform>
</Image>
</Window>

View File

@@ -0,0 +1,29 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
namespace ClaudeDo.Ui.Views.Controls;
/// <summary>
/// Borderless, transparent, topmost, click-through window that hosts the translucent drag
/// "ghost" — a snapshot of the row being dragged. It never activates (so the source window
/// keeps pointer capture) and is repositioned to the screen cursor on every captured move.
/// </summary>
public partial class DragGhostWindow : Window
{
public DragGhostWindow() => InitializeComponent();
/// <summary>
/// Show <paramref name="image"/> at <paramref name="logicalWidth"/>×<paramref name="logicalHeight"/>
/// with <paramref name="pad"/> of slack around it so the tilt isn't clipped by the window bounds.
/// </summary>
public void SetImage(IImage image, double logicalWidth, double logicalHeight, double pad)
{
GhostImage.Source = image;
GhostImage.Width = logicalWidth;
GhostImage.Height = logicalHeight;
GhostImage.Margin = new Thickness(pad);
Width = logicalWidth + pad * 2;
Height = logicalHeight + pad * 2;
}
}

View File

@@ -0,0 +1,24 @@
using System;
using Avalonia;
namespace ClaudeDo.Ui.Views.Controls;
/// <summary>
/// Pure, DPI-aware screen-rectangle hit testing for the custom drag. Kept free of any view
/// dependency so the "is the cursor over that window?" decision is unit-testable.
/// </summary>
public static class DragHitTest
{
/// <summary>
/// True when <paramref name="cursor"/> (physical px) falls inside a window whose top-left is
/// <paramref name="position"/> (physical px) and whose <paramref name="clientSize"/> is in
/// logical units at the given <paramref name="scaling"/>.
/// </summary>
public static bool WindowContains(PixelPoint position, Size clientSize, double scaling, PixelPoint cursor)
{
var right = position.X + (int)Math.Round(clientSize.Width * scaling);
var bottom = position.Y + (int)Math.Round(clientSize.Height * scaling);
return cursor.X >= position.X && cursor.X < right
&& cursor.Y >= position.Y && cursor.Y < bottom;
}
}

View File

@@ -0,0 +1,69 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media.Imaging;
namespace ClaudeDo.Ui.Views.Controls;
/// <summary>
/// Owns the lifecycle of the translucent follower window used by the custom task-row drag.
/// Snapshots the dragged control to a bitmap, shows a borderless topmost click-through window
/// that tracks the screen cursor across every top-level window, and tears it down on release.
/// </summary>
internal sealed class TaskDragController
{
// Slack around the snapshot so the tilted card isn't clipped by the window's own bounds.
private const double Pad = 36;
private DragGhostWindow? _ghost;
private PixelPoint _grabOffset; // physical px from the ghost window's top-left to the cursor
public bool IsActive => _ghost is not null;
/// <summary>
/// Begin the ghost. <paramref name="grabPointInSource"/> is the cursor position inside
/// <paramref name="source"/> (logical px) at grab time so the snapshot stays under the cursor.
/// </summary>
public void Begin(Control source, Point grabPointInSource, double scaling)
{
End();
var size = source.Bounds.Size;
if (size.Width < 1 || size.Height < 1) return;
var bitmap = Snapshot(source, scaling);
if (bitmap is null) return;
_ghost = new DragGhostWindow();
_ghost.SetImage(bitmap, size.Width, size.Height, Pad);
_grabOffset = new PixelPoint(
(int)Math.Round((grabPointInSource.X + Pad) * scaling),
(int)Math.Round((grabPointInSource.Y + Pad) * scaling));
_ghost.Show();
}
public void MoveTo(PixelPoint screenCursor)
{
if (_ghost is null) return;
_ghost.Position = new PixelPoint(screenCursor.X - _grabOffset.X, screenCursor.Y - _grabOffset.Y);
}
public void End()
{
_ghost?.Close();
_ghost = null;
}
private static RenderTargetBitmap? Snapshot(Control source, double scaling)
{
var size = source.Bounds.Size;
var pixelSize = new PixelSize(
Math.Max(1, (int)Math.Ceiling(size.Width * scaling)),
Math.Max(1, (int)Math.Ceiling(size.Height * scaling)));
var rtb = new RenderTargetBitmap(pixelSize, new Vector(96 * scaling, 96 * scaling));
rtb.Render(source);
return rtb;
}
}

View File

@@ -30,6 +30,7 @@
<Border Grid.Column="1" Classes="task-row"
Margin="0"
Classes.selected="{Binding IsSelected}"
Classes.dragging="{Binding IsDragging}"
Classes.done="{Binding Done}">
<Border.ContextMenu>
<ContextMenu>