181 lines
6.5 KiB
C#
181 lines
6.5 KiB
C#
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Input;
|
|
using Avalonia.Interactivity;
|
|
using Avalonia.VisualTree;
|
|
using ClaudeDo.Ui.ViewModels.Islands;
|
|
using ClaudeDo.Ui.ViewModels.Modals;
|
|
using ClaudeDo.Ui.Views.Modals;
|
|
|
|
namespace ClaudeDo.Ui.Views.Islands;
|
|
|
|
public partial class TasksIslandView : UserControl
|
|
{
|
|
private static readonly DataFormat<string> TaskRowFormat =
|
|
DataFormat.CreateStringApplicationFormat("claudedo-task-row");
|
|
|
|
public TasksIslandView()
|
|
{
|
|
InitializeComponent();
|
|
AddHandler(PointerPressedEvent, OnTunnelPointerPressed, RoutingStrategies.Tunnel);
|
|
DataContextChanged += (_, _) =>
|
|
{
|
|
if (DataContext is TasksIslandViewModel vm)
|
|
{
|
|
vm.FocusAddTaskRequested += (_, _) => AddTaskBox.Focus();
|
|
vm.ShowUnfinishedPlanningModal = async (modalVm) =>
|
|
{
|
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
|
if (owner is null) { modalVm.CancelCommand.Execute(null); return; }
|
|
var modal = new UnfinishedPlanningModalView { DataContext = modalVm };
|
|
// Closing via the OS title-bar (if ever enabled) also resolves the TCS.
|
|
modal.Closed += (_, _) => modalVm.CancelCommand.Execute(null);
|
|
await modal.ShowDialog(owner);
|
|
// ShowDialog completes once the window is closed (CloseAction or OS close).
|
|
};
|
|
}
|
|
};
|
|
}
|
|
|
|
private async void OnTunnelPointerPressed(object? sender, PointerPressedEventArgs e)
|
|
{
|
|
if (DataContext is not TasksIslandViewModel vm) return;
|
|
if (e.Source is not Visual src) return;
|
|
|
|
var button = src as Button ?? src.FindAncestorOfType<Button>();
|
|
if (button?.DataContext is not TaskRowViewModel row) return;
|
|
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 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.
|
|
var nestedInsideButton = button.Parent is Visual parentVisual
|
|
&& parentVisual.FindAncestorOfType<Button>() is not null;
|
|
if (nestedInsideButton) return;
|
|
|
|
if (!vm.CanReorder || row.IsRunning) return;
|
|
|
|
var data = new DataTransfer();
|
|
data.Add(DataTransferItem.Create(TaskRowFormat, row.Id));
|
|
try
|
|
{
|
|
await DragDrop.DoDragDropAsync(e, data, DragDropEffects.Move);
|
|
}
|
|
finally
|
|
{
|
|
vm.ClearDropHints();
|
|
}
|
|
}
|
|
|
|
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)
|
|
{
|
|
if (DataContext is not TasksIslandViewModel vm) { e.DragEffects = DragDropEffects.None; return; }
|
|
if (!e.DataTransfer?.Contains(TaskRowFormat) ?? true)
|
|
{
|
|
e.DragEffects = DragDropEffects.None;
|
|
vm.ClearDropHints();
|
|
return;
|
|
}
|
|
if (sender is not Button b || b.DataContext is not TaskRowViewModel target || target.IsRunning)
|
|
{
|
|
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(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.
|
|
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;
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
vm.SetDropHint(hintRow, hintBelow);
|
|
e.DragEffects = DragDropEffects.Move;
|
|
}
|
|
|
|
private static TaskRowViewModel? FindNextInSameSection(TasksIslandViewModel vm, TaskRowViewModel row)
|
|
{
|
|
foreach (var section in new[] { vm.OverdueItems, vm.OpenItems, vm.CompletedItems })
|
|
{
|
|
var idx = section.IndexOf(row);
|
|
if (idx >= 0) return idx + 1 < section.Count ? section[idx + 1] : null;
|
|
}
|
|
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;
|
|
}
|
|
}
|