fix(ui): auto-scroll the lists sidebar while dragging near its top/bottom edge

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-10-02 17:16:34 +02:00
co-authored by Claude Sonnet 5.5
parent ff7197c410
commit bba7117d54
3 changed files with 91 additions and 1 deletions
@@ -255,7 +255,7 @@
</Border>
<!-- ── Scrollable body ── -->
<ScrollViewer>
<ScrollViewer x:Name="BodyScroll" DragDrop.AllowDrop="True">
<StackPanel Margin="6,0,6,4">
<!-- SMART LISTS section -->
@@ -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;
/// <summary>Edge-band scroll step for dragging inside the lists sidebar.</summary>
public static class ListsAutoScroll
{
private const double Band = 40;
private const double MaxStep = 14;
/// <summary>Per-tick scroll delta for a pointer at <paramref name="y"/> in a viewport of <paramref name="height"/>: negative near the top, positive near the bottom, 0 outside the bands.</summary>
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<string> ListRowFormat =
@@ -33,12 +51,42 @@ public partial class ListsIslandView : UserControl
_pendingFolderDragPress = null;
}
private DispatcherTimer? _autoScrollTimer;
private double _autoScrollDelta;
/// <summary>Keeps the sidebar scrolling while the drag pointer sits in an edge band; DragOver alone may not repeat on a still pointer.</summary>
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();
}
}
@@ -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);
}
}