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:
@@ -407,6 +407,8 @@
|
|||||||
<BrushTransition Property="Background" Duration="0:0:0.12" />
|
<BrushTransition Property="Background" Duration="0:0:0.12" />
|
||||||
<BrushTransition Property="BorderBrush" Duration="0:0:0.12" />
|
<BrushTransition Property="BorderBrush" Duration="0:0:0.12" />
|
||||||
<ThicknessTransition Property="Margin" Duration="0:0:0.15" />
|
<ThicknessTransition Property="Margin" Duration="0:0:0.15" />
|
||||||
|
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.12" />
|
||||||
|
<DoubleTransition Property="Opacity" Duration="0:0:0.12" />
|
||||||
</Transitions>
|
</Transitions>
|
||||||
</Setter>
|
</Setter>
|
||||||
</Style>
|
</Style>
|
||||||
@@ -417,6 +419,13 @@
|
|||||||
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
</Style>
|
</Style>
|
||||||
|
<!-- "Grabbed" row: lift + slight scale + lower opacity + shadow while the custom drag runs. -->
|
||||||
|
<Style Selector="Border.task-row.dragging">
|
||||||
|
<Setter Property="Opacity" Value="0.55" />
|
||||||
|
<Setter Property="RenderTransform" Value="scale(1.03)" />
|
||||||
|
<Setter Property="BoxShadow" Value="0 10 26 0 #66000000" />
|
||||||
|
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!-- Checkbox indicator (the 18px circle that replaces the native CheckBox template) -->
|
<!-- Checkbox indicator (the 18px circle that replaces the native CheckBox template) -->
|
||||||
<Style Selector="Ellipse.task-check">
|
<Style Selector="Ellipse.task-check">
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private bool _parentInView = true;
|
[ObservableProperty] private bool _parentInView = true;
|
||||||
[ObservableProperty] private int _roadblockCount;
|
[ObservableProperty] private int _roadblockCount;
|
||||||
[ObservableProperty] private bool _isRefining;
|
[ObservableProperty] private bool _isRefining;
|
||||||
|
// Set by the custom drag while this row is being dragged — drives the "grabbed" row style.
|
||||||
|
[ObservableProperty] private bool _isDragging;
|
||||||
|
|
||||||
public bool CanRefine => Status == TaskStatus.Idle && PlanningPhase == PlanningPhase.None && !IsRefining;
|
public bool CanRefine => Status == TaskStatus.Idle && PlanningPhase == PlanningPhase.None && !IsRefining;
|
||||||
|
|
||||||
|
|||||||
22
src/ClaudeDo.Ui/Views/Controls/DragGhostWindow.axaml
Normal file
22
src/ClaudeDo.Ui/Views/Controls/DragGhostWindow.axaml
Normal 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>
|
||||||
29
src/ClaudeDo.Ui/Views/Controls/DragGhostWindow.axaml.cs
Normal file
29
src/ClaudeDo.Ui/Views/Controls/DragGhostWindow.axaml.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/ClaudeDo.Ui/Views/Controls/DragHitTest.cs
Normal file
24
src/ClaudeDo.Ui/Views/Controls/DragHitTest.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/ClaudeDo.Ui/Views/Controls/TaskDragController.cs
Normal file
69
src/ClaudeDo.Ui/Views/Controls/TaskDragController.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
<Border Grid.Column="1" Classes="task-row"
|
<Border Grid.Column="1" Classes="task-row"
|
||||||
Margin="0"
|
Margin="0"
|
||||||
Classes.selected="{Binding IsSelected}"
|
Classes.selected="{Binding IsSelected}"
|
||||||
|
Classes.dragging="{Binding IsDragging}"
|
||||||
Classes.done="{Binding Done}">
|
Classes.done="{Binding Done}">
|
||||||
<Border.ContextMenu>
|
<Border.ContextMenu>
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
|
|||||||
44
tests/ClaudeDo.Ui.Tests/DragHitTestTests.cs
Normal file
44
tests/ClaudeDo.Ui.Tests/DragHitTestTests.cs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using ClaudeDo.Ui.Views.Controls;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests;
|
||||||
|
|
||||||
|
public class DragHitTestTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Cursor_inside_window_rect_is_contained()
|
||||||
|
{
|
||||||
|
var pos = new PixelPoint(100, 100);
|
||||||
|
var client = new Size(800, 600);
|
||||||
|
Assert.True(DragHitTest.WindowContains(pos, client, 1.0, new PixelPoint(500, 400)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Cursor_outside_window_rect_is_not_contained()
|
||||||
|
{
|
||||||
|
var pos = new PixelPoint(100, 100);
|
||||||
|
var client = new Size(800, 600);
|
||||||
|
Assert.False(DragHitTest.WindowContains(pos, client, 1.0, new PixelPoint(50, 50))); // above-left
|
||||||
|
Assert.False(DragHitTest.WindowContains(pos, client, 1.0, new PixelPoint(950, 400))); // past right edge
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Top_left_is_inclusive_bottom_right_is_exclusive()
|
||||||
|
{
|
||||||
|
var pos = new PixelPoint(100, 100);
|
||||||
|
var client = new Size(800, 600);
|
||||||
|
Assert.True(DragHitTest.WindowContains(pos, client, 1.0, new PixelPoint(100, 100))); // top-left corner
|
||||||
|
Assert.False(DragHitTest.WindowContains(pos, client, 1.0, new PixelPoint(900, 700))); // bottom-right corner
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Scaling_expands_the_physical_rect()
|
||||||
|
{
|
||||||
|
var pos = new PixelPoint(100, 100);
|
||||||
|
var client = new Size(800, 600); // logical
|
||||||
|
// At 2x the right edge is 100 + 1600 = 1700, so a point at x=1600 now falls inside.
|
||||||
|
Assert.False(DragHitTest.WindowContains(pos, client, 1.0, new PixelPoint(1600, 400)));
|
||||||
|
Assert.True(DragHitTest.WindowContains(pos, client, 2.0, new PixelPoint(1600, 400)));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user