feat(ui): replace OLE task-row drag with custom ghost drag

Task rows now drive a hand-built pointer-capture drag instead of
DragDrop.DoDragDropAsync: armed on press, begins past a 4px threshold so a
plain click still selects. The ghost follows the screen cursor across windows;
on release the action is decided by what is under the cursor -- over the
Mission Control window queues the task (geometric DragHitTest, no OLE drop),
over another row in the same user list reorders, anywhere else cancels and
restores the row. Drag starts from any list kind (drag-to-queue everywhere)
but reorder-on-drop stays gated on CanReorder. Removes the obsolete OLE
TaskRowFormat path from both the source and MissionControlView (pane
PaneFormat reorder is untouched).
This commit is contained in:
Mika Kuns
2026-06-25 22:39:30 +02:00
parent 05aec8ebfa
commit bec26b2232
3 changed files with 171 additions and 124 deletions

View File

@@ -95,13 +95,7 @@
<Button Classes="flat" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).SelectCommand}"
CommandParameter="{Binding}"
PointerPressed="OnRowPointerPressed"
PointerMoved="OnRowPointerMoved"
PointerReleased="OnRowPointerReleased"
DragDrop.AllowDrop="True"
DragDrop.DragOver="OnRowDragOver"
DragDrop.Drop="OnRowDrop">
CommandParameter="{Binding}">
<islands:TaskRowView/>
</Button>
</DataTemplate>
@@ -120,13 +114,7 @@
<Button Classes="flat" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).SelectCommand}"
CommandParameter="{Binding}"
PointerPressed="OnRowPointerPressed"
PointerMoved="OnRowPointerMoved"
PointerReleased="OnRowPointerReleased"
DragDrop.AllowDrop="True"
DragDrop.DragOver="OnRowDragOver"
DragDrop.Drop="OnRowDrop">
CommandParameter="{Binding}">
<islands:TaskRowView/>
</Button>
</DataTemplate>
@@ -159,13 +147,7 @@
<Button Classes="flat" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).SelectCommand}"
CommandParameter="{Binding}"
PointerPressed="OnRowPointerPressed"
PointerMoved="OnRowPointerMoved"
PointerReleased="OnRowPointerReleased"
DragDrop.AllowDrop="True"
DragDrop.DragOver="OnRowDragOver"
DragDrop.Drop="OnRowDrop">
CommandParameter="{Binding}">
<islands:TaskRowView/>
</Button>
</DataTemplate>

View File

@@ -1,28 +1,43 @@
using System;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Threading;
using Avalonia.VisualTree;
using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.ViewModels.Modals;
using ClaudeDo.Ui.Views.Controls;
using ClaudeDo.Ui.Views.MissionControl;
using ClaudeDo.Ui.Views.Modals;
namespace ClaudeDo.Ui.Views.Islands;
public partial class TasksIslandView : UserControl
{
// Public so the Mission Control window can accept the same drag payload (drop-to-queue).
public static readonly DataFormat<string> TaskRowFormat =
DataFormat.CreateStringApplicationFormat("claudedo-task-row");
private readonly TaskDragController _drag = new();
// Custom-drag gesture state. The drag is ARMED on press and BEGINS once the pointer moves
// past the threshold, so a plain click still selects the row.
private const double DragThreshold = 4;
private Point _pressPoint;
private TaskRowViewModel? _pressRow;
private Control? _pressControl;
private bool _dragArmed;
private bool _dragging;
public TasksIslandView()
{
InitializeComponent();
AddHandler(PointerPressedEvent, OnTunnelPointerPressed, RoutingStrategies.Tunnel);
AddHandler(PointerMovedEvent, OnPointerMovedDrag, RoutingStrategies.Tunnel);
AddHandler(PointerReleasedEvent, OnPointerReleasedDrag, RoutingStrategies.Tunnel);
AddHandler(PointerCaptureLostEvent, OnPointerCaptureLost);
DataContextChanged += (_, _) =>
{
if (DataContext is TasksIslandViewModel vm)
@@ -103,9 +118,15 @@ public partial class TasksIslandView : UserControl
return await tcs.Task;
}
private async void OnTunnelPointerPressed(object? sender, PointerPressedEventArgs e)
// ── Custom ghost drag ────────────────────────────────────────────────────
// Replaces both the OLE DoDragDropAsync reorder and the OLE drop-to-queue path: a hand-built
// drag (pointer capture + a transparent topmost ghost window) is the only way to get a
// translucent follower that crosses from this window into the separate Mission Control window.
private void OnTunnelPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (DataContext is not TasksIslandViewModel vm) return;
ResetPressState();
if (DataContext is not TasksIslandViewModel) return;
if (e.Source is not Visual src) return;
var button = src as Button ?? src.FindAncestorOfType<Button>();
@@ -113,8 +134,7 @@ public partial class TasksIslandView : UserControl
if (!e.GetCurrentPoint(button).Properties.IsLeftButtonPressed) return;
// Select now so the details pane updates whether the gesture becomes a click or a drag.
// (Button.Click doesn't fire once DoDragDropAsync captures the pointer.)
vm.SelectedTask = row;
if (DataContext is TasksIslandViewModel vm) vm.SelectedTask = row;
// If the click landed on a nested Button (e.g. the done-toggle checkbox or star),
// don't start a drag — that would capture the pointer and swallow the inner Click.
@@ -122,79 +142,171 @@ public partial class TasksIslandView : UserControl
&& parentVisual.FindAncestorOfType<Button>() is not null;
if (nestedInsideButton) return;
if (!vm.CanReorder || row.IsRunning) return;
// Running tasks can be neither reordered nor re-queued.
if (row.IsRunning) return;
var data = new DataTransfer();
data.Add(DataTransferItem.Create(TaskRowFormat, row.Id));
try
// Arm the drag for ANY list kind so drag-to-queue works everywhere; reorder-on-drop is
// still gated on CanReorder (user lists only).
_pressPoint = e.GetPosition(this);
_pressRow = row;
_pressControl = button;
_dragArmed = true;
}
private void OnPointerMovedDrag(object? sender, PointerEventArgs e)
{
if (!_dragArmed && !_dragging) return;
if (TopLevel.GetTopLevel(this) is not { } topLevel) return;
if (_dragArmed && !_dragging)
{
await DragDrop.DoDragDropAsync(e, data, DragDropEffects.Move);
var p = e.GetPosition(this);
if (Math.Abs(p.X - _pressPoint.X) < DragThreshold && Math.Abs(p.Y - _pressPoint.Y) < DragThreshold)
return;
BeginDrag(e, topLevel);
}
finally
if (_dragging)
{
vm.ClearDropHints();
_drag.MoveTo(this.PointToScreen(e.GetPosition(this)));
UpdateReorderHint(e, topLevel);
}
}
private void OnRowPointerPressed(object? sender, PointerPressedEventArgs e) { }
private void OnRowPointerMoved(object? sender, PointerEventArgs e) { }
private void OnRowPointerReleased(object? sender, PointerReleasedEventArgs e) { }
private void OnRowDragOver(object? sender, DragEventArgs e)
private void BeginDrag(PointerEventArgs e, TopLevel topLevel)
{
if (DataContext is not TasksIslandViewModel vm) { e.DragEffects = DragDropEffects.None; return; }
if (!e.DataTransfer?.Contains(TaskRowFormat) ?? true)
if (_pressControl is null || _pressRow is null) return;
// Snapshot the row BEFORE applying the "grabbed" style so the ghost stays crisp.
_drag.Begin(_pressControl, e.GetPosition(_pressControl), topLevel.RenderScaling);
_pressRow.IsDragging = true;
_dragging = true;
e.Pointer.Capture(this);
}
private async void OnPointerReleasedDrag(object? sender, PointerReleasedEventArgs e)
{
if (!_dragArmed && !_dragging) return;
var wasDragging = _dragging;
var row = _pressRow;
var topLevel = TopLevel.GetTopLevel(this);
var screen = wasDragging && topLevel is not null
? this.PointToScreen(e.GetPosition(this))
: default;
EndDrag(e);
if (!wasDragging || row is null || topLevel is null) return;
// 1) Released over the Mission Control window → queue the task.
if (MissionControlUnder(screen) is { } mc)
{
e.DragEffects = DragDropEffects.None;
vm.ClearDropHints();
await mc.EnqueueTaskAsync(row.Id);
return;
}
if (sender is not Button b || b.DataContext is not TaskRowViewModel target || target.IsRunning)
// 2) Released over another row in the same user list → reorder.
if (DataContext is TasksIslandViewModel vm && vm.CanReorder)
{
var targetButton = RowButtonAt(e, topLevel);
if (targetButton?.DataContext is TaskRowViewModel target
&& !ReferenceEquals(target, row) && !target.IsRunning)
{
var placeBelow = e.GetPosition(targetButton).Y > targetButton.Bounds.Height / 2;
await vm.ReorderAsync(row, target, placeBelow);
return;
}
}
// 3) Anywhere else → cancel; EndDrag already restored the source row.
}
private void OnPointerCaptureLost(object? sender, PointerCaptureLostEventArgs e)
{
// We just took capture ourselves (stealing it from the row Button when the drag began) —
// that is not a real loss, so don't tear the drag down.
if (ReferenceEquals(e.Pointer.Captured, this)) return;
if (!_dragArmed && !_dragging) return;
if (_pressRow is not null) _pressRow.IsDragging = false;
if (DataContext is TasksIslandViewModel vm) vm.ClearDropHints();
_drag.End();
ResetPressState();
}
private void EndDrag(PointerEventArgs e)
{
if (_pressRow is not null) _pressRow.IsDragging = false;
if (DataContext is TasksIslandViewModel vm) vm.ClearDropHints();
_drag.End();
if (_dragging) e.Pointer.Capture(null);
ResetPressState();
}
private void ResetPressState()
{
_dragArmed = false;
_dragging = false;
_pressRow = null;
_pressControl = null;
}
// Live drop-hint while dragging over rows in the source (user) list.
private void UpdateReorderHint(PointerEventArgs e, TopLevel topLevel)
{
if (DataContext is not TasksIslandViewModel vm) return;
if (!vm.CanReorder) { vm.ClearDropHints(); return; }
var targetButton = RowButtonAt(e, topLevel);
if (targetButton?.DataContext is not TaskRowViewModel target
|| target.IsRunning || ReferenceEquals(target, _pressRow))
{
e.DragEffects = DragDropEffects.None;
vm.ClearDropHints();
return;
}
var sourceId = e.DataTransfer?.TryGetValue(TaskRowFormat);
if (string.IsNullOrEmpty(sourceId) || sourceId == target.Id)
{
e.DragEffects = DragDropEffects.None;
vm.ClearDropHints();
return;
}
var placeBelow = e.GetPosition(targetButton).Y > targetButton.Bounds.Height / 2;
var placeBelow = e.GetPosition(b).Y > b.Bounds.Height / 2;
// Canonicalize: "drop below X" == "drop above X+1". Render the indicator
// above X+1 when there is one; only the last row in a section shows a below-line.
// Canonicalize: "drop below X" == "drop above X+1". Render the indicator above X+1 when
// there is one; only the last row in a section shows a below-line.
TaskRowViewModel hintRow = target;
bool hintBelow = false;
if (placeBelow)
{
var next = FindNextInSameSection(vm, target);
if (next is not null && !next.IsRunning)
{
hintRow = next;
hintBelow = false;
}
else
{
hintRow = target;
hintBelow = true;
}
if (next is not null && !next.IsRunning) { hintRow = next; hintBelow = false; }
else { hintRow = target; hintBelow = true; }
}
// A hint that lands right where the dragged row already sits is a no-op.
if (hintRow.Id == sourceId)
{
e.DragEffects = DragDropEffects.None;
vm.ClearDropHints();
return;
}
if (_pressRow is not null && hintRow.Id == _pressRow.Id) { vm.ClearDropHints(); return; }
vm.SetDropHint(hintRow, hintBelow);
e.DragEffects = DragDropEffects.Move;
}
// The row-level Button under the cursor, found by geometric hit-test on the source window
// (works while the pointer is captured to this control).
private static Button? RowButtonAt(PointerEventArgs e, TopLevel topLevel)
{
var pt = e.GetPosition((Visual)topLevel);
if (topLevel.InputHitTest(pt) is not Visual hit) return null;
var button = hit as Button ?? hit.FindAncestorOfType<Button>();
while (button is not null && button.DataContext is not TaskRowViewModel)
button = (button.Parent as Visual)?.FindAncestorOfType<Button>();
return button?.DataContext is TaskRowViewModel ? button : null;
}
// The Mission Control view model whose window contains the release point, if any.
private static MissionControlViewModel? MissionControlUnder(PixelPoint screen)
{
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
return null;
foreach (var w in desktop.Windows)
{
if (w is not MissionControlWindow mc || !mc.IsVisible) continue;
if (DragHitTest.WindowContains(mc.Position, mc.ClientSize, mc.RenderScaling, screen))
return mc.DataContext as MissionControlViewModel;
}
return null;
}
private static TaskRowViewModel? FindNextInSameSection(TasksIslandViewModel vm, TaskRowViewModel row)
@@ -206,41 +318,4 @@ public partial class TasksIslandView : UserControl
}
return null;
}
private async void OnRowDrop(object? sender, DragEventArgs e)
{
if (DataContext is not TasksIslandViewModel vm) return;
try
{
if (sender is not Button b || b.DataContext is not TaskRowViewModel target) return;
if (target.IsRunning) return;
var sourceId = e.DataTransfer?.TryGetValue(TaskRowFormat);
if (string.IsNullOrEmpty(sourceId) || sourceId == target.Id) return;
var source = FindRowById(vm, sourceId);
if (source is null || source.IsRunning) return;
var placeBelow = e.GetPosition(b).Y > b.Bounds.Height / 2;
// Clear the 6px drop-hint spacer BEFORE the move so the reorder animates
// into its truly-final layout in one step (otherwise the row lands in the
// gap, then the gap collapses and everything shifts up a second time).
vm.ClearDropHints();
await vm.ReorderAsync(source, target, placeBelow);
}
catch
{
vm.ClearDropHints();
throw;
}
}
private static TaskRowViewModel? FindRowById(TasksIslandViewModel vm, string id)
{
foreach (var r in vm.Items)
if (r.Id == id) return r;
return null;
}
}

View File

@@ -5,7 +5,6 @@ using Avalonia.Interactivity;
using Avalonia.VisualTree;
using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.Views.Islands;
namespace ClaudeDo.Ui.Views.MissionControl;
@@ -25,9 +24,7 @@ public partial class MissionControlView : UserControl
private void OnPaneDragOver(object? sender, DragEventArgs e)
{
var dt = e.DataTransfer;
var accept = (dt?.Contains(PaneFormat) ?? false)
|| (dt?.Contains(TasksIslandView.TaskRowFormat) ?? false);
e.DragEffects = accept ? DragDropEffects.Move : DragDropEffects.None;
e.DragEffects = (dt?.Contains(PaneFormat) ?? false) ? DragDropEffects.Move : DragDropEffects.None;
}
private void OnPaneDrop(object? sender, DragEventArgs e)
@@ -36,15 +33,8 @@ public partial class MissionControlView : UserControl
var dt = e.DataTransfer;
if (dt is null) return;
// A task dragged from the main app → queue it.
var taskId = dt.TryGetValue(TasksIslandView.TaskRowFormat);
if (!string.IsNullOrEmpty(taskId))
{
_ = vm.EnqueueTaskAsync(taskId);
return;
}
// A pane dragged within the grid → reorder.
// A pane dragged within the grid → reorder. (Drag-to-queue from the main app now arrives
// via the custom ghost drag's screen hit-test, not an OLE drop.)
var draggedId = dt.TryGetValue(PaneFormat);
if (string.IsNullOrEmpty(draggedId)) return;
if (e.Source is not Avalonia.Visual src) return;