refactor(ui): flatten TasksIsland's task list into one virtualized ListBox

Replace the three ItemsControl sections (Overdue/Open/Completed) with a single
flat Rows collection (HeaderRow | TaskRowViewModel) bound to a ListBox +
VirtualizingStackPanel, so the realized container count stays bounded instead
of growing with the list (measured: ~7-8 realized containers at any scroll
position with 1000 source rows). Group headers become regular row entries,
each section is simply omitted from Rows when empty, and the Completed
section's Clear-completed action moves onto the header row itself.

SectionFor/ReorderAsync/FindNextInSameSection now read a row's section off
the nearest preceding HeaderRow in Rows instead of three separate bound
collections, and ScrollSelectedIntoView goes through ListBox.ScrollIntoView so
it still works for rows outside the realized window. Drag/reorder behavior,
grouping/counts, and the Clear-completed button are unchanged.

Auto-scroll-while-dragging and the cross-section reorder bug are explicitly
out of scope (follow-up tasks).
This commit is contained in:
mika kuns
2026-08-10 16:18:55 +02:00
parent 514d6111fe
commit ec6c230822
6 changed files with 369 additions and 139 deletions
@@ -1,5 +1,4 @@
using System;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
@@ -62,19 +61,14 @@ public partial class TasksIslandView : UserControl
};
}
// Bring the selected row into view — the task list is a plain ItemsControl with no
// built-in selection scrolling, so a programmatic select (e.g. Mission Control's
// "Open in app") would otherwise highlight a row that stays off-screen.
// Bring the selected row into view — a programmatic select (e.g. Mission Control's "Open in
// app") can target a row that isn't currently realized. RowsListBox.ScrollIntoView is
// virtualization-aware (it realizes the container as needed); a visual-tree search for an
// existing Button, as this used to do, only finds rows already on screen.
private void ScrollSelectedIntoView()
{
if (DataContext is not TasksIslandViewModel vm || vm.SelectedTask is not { } target) return;
Dispatcher.UIThread.Post(() =>
{
var match = this.GetVisualDescendants()
.OfType<Button>()
.FirstOrDefault(b => ReferenceEquals(b.DataContext, target));
match?.BringIntoView();
}, DispatcherPriority.Background);
Dispatcher.UIThread.Post(() => RowsListBox.ScrollIntoView(target), DispatcherPriority.Background);
}
private async System.Threading.Tasks.Task<bool> ShowConfirmAsync(string message)
@@ -363,13 +357,12 @@ public partial class TasksIslandView : UserControl
return null;
}
// The next row in Rows is only "in the same section" while it's a TaskRowViewModel —
// hitting a HeaderRow (or the end of the list) means row was the last one in its section.
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;
var idx = vm.Rows.IndexOf(row);
if (idx < 0 || idx + 1 >= vm.Rows.Count) return null;
return vm.Rows[idx + 1] as TaskRowViewModel;
}
}