diff --git a/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml b/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml index 5dbc829e..fec2c103 100644 --- a/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml @@ -255,7 +255,7 @@ - + diff --git a/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs b/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs index dc1b4838..bebaed73 100644 --- a/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml.cs @@ -5,6 +5,7 @@ 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; @@ -13,6 +14,23 @@ using ClaudeDo.Ui.Views.Modals; namespace ClaudeDo.Ui.Views.Islands; +/// Edge-band scroll step for dragging inside the lists sidebar. +public static class ListsAutoScroll +{ + private const double Band = 40; + private const double MaxStep = 14; + + /// Per-tick scroll delta for a pointer at in a viewport of : negative near the top, positive near the bottom, 0 outside the bands. + public static double Delta(double y, double height) + { + var band = Math.Min(Band, height / 2); + if (band <= 0) return 0; + if (y < band) return -MaxStep * Math.Min(1, (band - y) / band); + if (y > height - band) return MaxStep * Math.Min(1, (y - (height - band)) / band); + return 0; + } +} + public partial class ListsIslandView : UserControl { private static readonly DataFormat ListRowFormat = @@ -33,12 +51,42 @@ public partial class ListsIslandView : UserControl _pendingFolderDragPress = null; } + private DispatcherTimer? _autoScrollTimer; + private double _autoScrollDelta; + + /// Keeps the sidebar scrolling while the drag pointer sits in an edge band; DragOver alone may not repeat on a still pointer. + private void OnBodyDragOver(object? sender, DragEventArgs e) + { + // Gaps and section labels are only drop targets so DragOver reaches us — they accept nothing. + if (FindListItemBorder(e.Source as Visual) is null) e.DragEffects = DragDropEffects.None; + + _autoScrollDelta = ListsAutoScroll.Delta(e.GetPosition(BodyScroll).Y, BodyScroll.Bounds.Height); + if (_autoScrollDelta == 0) { StopAutoScroll(); return; } + + _autoScrollTimer ??= new DispatcherTimer(TimeSpan.FromMilliseconds(16), DispatcherPriority.Background, (_, _) => + { + var maxY = Math.Max(0, BodyScroll.Extent.Height - BodyScroll.Viewport.Height); + var y = Math.Clamp(BodyScroll.Offset.Y + _autoScrollDelta, 0, maxY); + BodyScroll.Offset = new Vector(BodyScroll.Offset.X, y); + }); + _autoScrollTimer.Start(); + } + + private void StopAutoScroll() + { + _autoScrollDelta = 0; + _autoScrollTimer?.Stop(); + } + public ListsIslandView() { InitializeComponent(); AddHandler(PointerPressedEvent, OnTunnelPointerPressed, RoutingStrategies.Tunnel); AddHandler(PointerMovedEvent, OnTunnelPointerMoved, RoutingStrategies.Tunnel); AddHandler(PointerReleasedEvent, (_, _) => CancelPendingFolderDrag(), RoutingStrategies.Tunnel); + BodyScroll.AddHandler(DragDrop.DragOverEvent, OnBodyDragOver, RoutingStrategies.Bubble, handledEventsToo: true); + BodyScroll.AddHandler(DragDrop.DragLeaveEvent, (_, _) => StopAutoScroll(), RoutingStrategies.Bubble, handledEventsToo: true); + BodyScroll.AddHandler(DragDrop.DropEvent, (_, _) => StopAutoScroll(), RoutingStrategies.Bubble, handledEventsToo: true); DataContextChanged += (_, _) => { if (DataContext is ListsIslandViewModel vm) @@ -92,6 +140,7 @@ public partial class ListsIslandView : UserControl } finally { + StopAutoScroll(); vm.ClearDropHints(); } } @@ -115,6 +164,7 @@ public partial class ListsIslandView : UserControl } finally { + StopAutoScroll(); vm.ClearDropHints(); } } diff --git a/tests/ClaudeDo.Ui.Tests/ListsAutoScrollTests.cs b/tests/ClaudeDo.Ui.Tests/ListsAutoScrollTests.cs new file mode 100644 index 00000000..a49e4c6c --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/ListsAutoScrollTests.cs @@ -0,0 +1,40 @@ +using ClaudeDo.Ui.Views.Islands; +using Xunit; + +namespace ClaudeDo.Ui.Tests; + +public class ListsAutoScrollTests +{ + [Fact] + public void Middle_of_viewport_does_not_scroll() => + Assert.Equal(0, ListsAutoScroll.Delta(300, 600)); + + [Fact] + public void Top_band_scrolls_up_and_speeds_up_towards_the_edge() + { + var near = ListsAutoScroll.Delta(30, 600); + var edge = ListsAutoScroll.Delta(0, 600); + Assert.True(near < 0); + Assert.True(edge < near); + } + + [Fact] + public void Bottom_band_scrolls_down_and_speeds_up_towards_the_edge() + { + var near = ListsAutoScroll.Delta(570, 600); + var edge = ListsAutoScroll.Delta(600, 600); + Assert.True(near > 0); + Assert.True(edge > near); + } + + [Fact] + public void Pointer_outside_the_viewport_is_clamped_to_max_step() => + Assert.Equal(ListsAutoScroll.Delta(0, 600), ListsAutoScroll.Delta(-50, 600)); + + [Fact] + public void Tiny_or_empty_viewport_is_safe() + { + Assert.Equal(0, ListsAutoScroll.Delta(0, 0)); + Assert.True(ListsAutoScroll.Delta(5, 20) < 0); + } +}