using System.Linq; using Avalonia; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Layout; using Avalonia.Media; using Avalonia.VisualTree; using ClaudeDo.Ui.ViewModels; using ClaudeDo.Ui.ViewModels.Islands; using ClaudeDo.Ui.ViewModels.Modals; using ClaudeDo.Ui.Views.Modals; namespace ClaudeDo.Ui.Views.Islands; public partial class ListsIslandView : UserControl { private static readonly DataFormat ListRowFormat = DataFormat.CreateStringApplicationFormat("claudedo-list-row"); private static readonly DataFormat FolderRowFormat = DataFormat.CreateStringApplicationFormat("claudedo-folder-row"); // A folder drag only starts once the pointer has moved: DoDragDropAsync captures the pointer, // and starting it on press would swallow the Tapped that folds the folder. It only takes the // *pressed* args, so the press is kept around until the threshold is crossed. private ListFolderNavItemViewModel? _pendingFolderDrag; private PointerPressedEventArgs? _pendingFolderDragPress; private Point _folderDragOrigin; private void CancelPendingFolderDrag() { _pendingFolderDrag = null; _pendingFolderDragPress = null; } public ListsIslandView() { InitializeComponent(); AddHandler(PointerPressedEvent, OnTunnelPointerPressed, RoutingStrategies.Tunnel); AddHandler(PointerMovedEvent, OnTunnelPointerMoved, RoutingStrategies.Tunnel); AddHandler(PointerReleasedEvent, (_, _) => CancelPendingFolderDrag(), RoutingStrategies.Tunnel); DataContextChanged += (_, _) => { if (DataContext is ListsIslandViewModel vm) vm.FocusSearchRequested += (_, _) => SearchBox.Focus(); }; } private void OnItemTapped(object? sender, RoutedEventArgs e) { if (sender is Border { DataContext: ListNavItemViewModel item } && DataContext is ListsIslandViewModel vm) vm.SelectCommand.Execute(item); } private async void OnTunnelPointerPressed(object? sender, PointerPressedEventArgs e) { if (DataContext is not ListsIslandViewModel vm) return; if (e.Source is not Visual src) return; var border = FindListItemBorder(src); if (border is null) return; if (!e.GetCurrentPoint(border).Properties.IsLeftButtonPressed) return; if (border.DataContext is ListFolderNavItemViewModel folder) { _pendingFolderDrag = folder; _pendingFolderDragPress = e; _folderDragOrigin = e.GetPosition(this); return; } if (border.DataContext is not ListNavItemViewModel row || row.Kind != ListKind.User) return; // Double-click opens the list's settings instead of starting a drag. Handled here // because DoDragDropAsync captures the pointer and would swallow a DoubleTapped event. if (e.ClickCount == 2) { vm.OpenListSettingsCommand.Execute(row); return; } // Select now so the right pane updates whether the gesture becomes a click or a drag // (the Tapped handler doesn't fire once DoDragDropAsync captures the pointer). vm.SelectCommand.Execute(row); var data = new DataTransfer(); data.Add(DataTransferItem.Create(ListRowFormat, row.Id)); try { await DragDrop.DoDragDropAsync(e, data, DragDropEffects.Move); } finally { vm.ClearDropHints(); } } private async void OnTunnelPointerMoved(object? sender, PointerEventArgs e) { if (_pendingFolderDrag is not { } folder || _pendingFolderDragPress is not { } press) return; if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) { CancelPendingFolderDrag(); return; } var delta = e.GetPosition(this) - _folderDragOrigin; if (Math.Abs(delta.X) < 4 && Math.Abs(delta.Y) < 4) return; CancelPendingFolderDrag(); if (DataContext is not ListsIslandViewModel vm) return; var data = new DataTransfer(); data.Add(DataTransferItem.Create(FolderRowFormat, folder.Id)); try { await DragDrop.DoDragDropAsync(press, data, DragDropEffects.Move); } finally { vm.ClearDropHints(); } } private void OnFolderTapped(object? sender, RoutedEventArgs e) { if (sender is Border { DataContext: ListFolderNavItemViewModel folder } && DataContext is ListsIslandViewModel vm) vm.ToggleFolderCommand.Execute(folder); } /// The dragged row or folder, resolved from whichever of the two formats is present. private static object? DragSource(ListsIslandViewModel vm, DragEventArgs e) { if (e.DataTransfer is not { } data) return null; if (data.Contains(ListRowFormat)) return data.TryGetValue(ListRowFormat) is { } listId ? vm.UserLists.FirstOrDefault(r => r.Id == listId) : null; if (data.Contains(FolderRowFormat)) return data.TryGetValue(FolderRowFormat) is { } folderId ? vm.Folders.FirstOrDefault(f => f.Id == folderId) : null; return null; } private enum FolderDrop { None, Into, Beside } /// /// What a drop on a folder header means. For a list the middle half moves it *into* the folder /// (the only route into a collapsed or empty one) while the outer quarters insert next to the /// folder at the top level; a folder source has no "into" — folders never nest — so it splits /// in half. /// private static (FolderDrop Kind, bool PlaceBelow) ClassifyFolderDrop( object source, ListFolderNavItemViewModel folder, double y, double height) { if (source is ListFolderNavItemViewModel dragged) return ReferenceEquals(dragged, folder) ? (FolderDrop.None, false) : (FolderDrop.Beside, y > height / 2); if (source is not ListNavItemViewModel row) return (FolderDrop.None, false); if (y < height * 0.25) return (FolderDrop.Beside, false); if (y > height * 0.75) return (FolderDrop.Beside, true); return row.FolderId == folder.Id ? (FolderDrop.None, false) : (FolderDrop.Into, false); } private void OnFolderDragOver(object? sender, DragEventArgs e) { if (DataContext is not ListsIslandViewModel vm) { e.DragEffects = DragDropEffects.None; return; } if (sender is not Border b || b.DataContext is not ListFolderNavItemViewModel folder) { Reject(vm, e); return; } var source = DragSource(vm, e); if (source is null) { Reject(vm, e); return; } var (kind, placeBelow) = ClassifyFolderDrop(source, folder, e.GetPosition(b).Y, b.Bounds.Height); switch (kind) { case FolderDrop.Into: vm.SetFolderDropTarget(folder); e.DragEffects = DragDropEffects.Move; break; case FolderDrop.Beside: vm.SetDropHint(folder, placeBelow); e.DragEffects = DragDropEffects.Move; break; default: Reject(vm, e); break; } } private async void OnFolderDrop(object? sender, DragEventArgs e) { if (DataContext is not ListsIslandViewModel vm) return; try { if (sender is not Border b || b.DataContext is not ListFolderNavItemViewModel folder) return; var source = DragSource(vm, e); if (source is null) return; var (kind, placeBelow) = ClassifyFolderDrop(source, folder, e.GetPosition(b).Y, b.Bounds.Height); vm.ClearDropHints(); if (kind == FolderDrop.Into && source is ListNavItemViewModel row) await vm.MoveToFolderAsync(row, folder); else if (kind == FolderDrop.Beside) await vm.DropAsync(source, folder, placeBelow); } catch { vm.ClearDropHints(); throw; } } private void OnListDragOver(object? sender, DragEventArgs e) { if (DataContext is not ListsIslandViewModel vm) { e.DragEffects = DragDropEffects.None; return; } if (sender is not Border b || b.DataContext is not ListNavItemViewModel target || target.Kind != ListKind.User) { Reject(vm, e); return; } var source = DragSource(vm, e); if (source is null || ReferenceEquals(source, target)) { Reject(vm, e); return; } // A folder can only be dropped at the top level, so a row inside a folder is no target. if (source is ListFolderNavItemViewModel && target.FolderId is not null) { Reject(vm, e); return; } // Canonicalize: "drop below X" == "drop above X+1". Only the last row shows a below-line. object hintTarget = target; var hintBelow = false; if (e.GetPosition(b).Y > b.Bounds.Height / 2) { if (FindNextInContainer(vm, target) is { } next) hintTarget = next; else hintBelow = true; } if (ReferenceEquals(hintTarget, source)) { Reject(vm, e); return; } vm.SetDropHint(hintTarget, hintBelow); e.DragEffects = DragDropEffects.Move; } private async void OnListDrop(object? sender, DragEventArgs e) { if (DataContext is not ListsIslandViewModel vm) return; try { if (sender is not Border b || b.DataContext is not ListNavItemViewModel target || target.Kind != ListKind.User) return; var source = DragSource(vm, e); if (source is null || ReferenceEquals(source, target)) return; if (source is ListFolderNavItemViewModel && target.FolderId is not null) return; var placeBelow = e.GetPosition(b).Y > b.Bounds.Height / 2; vm.ClearDropHints(); await vm.DropAsync(source, target, placeBelow); } catch { vm.ClearDropHints(); throw; } } private static void Reject(ListsIslandViewModel vm, DragEventArgs e) { e.DragEffects = DragDropEffects.None; vm.ClearDropHints(); } private static Border? FindListItemBorder(Visual? v) { while (v is not null) { if (v is Border b && b.Classes.Contains("list-item")) return b; v = v.GetVisualParent(); } return null; } // Next item *within the same container* — the row below the last list of a folder belongs to // another container, so it is not a "drop above" candidate here. At the top level the next // item can be a folder, which is a valid hint target. private static object? FindNextInContainer(ListsIslandViewModel vm, ListNavItemViewModel row) { var container = vm.ContainerOf(row); var idx = container.IndexOf(row); if (idx < 0) return null; return idx + 1 < container.Count ? container[idx + 1] : null; } }