Desktop: build only the host cards near the view, with a virtualising wrap panel for the board
ci / build and test (push) Successful in 3m39s
ci / android head (push) Successful in 3m59s
ci / api image (push) Successful in 5s
ci / desktop nightly (push) Failing after 37s

This commit is contained in:
jaap-jan committed 2026-10-02 16:20:51 +02:00
1 parent 41f368ae75
commit 656a33b4b1
3 files changed
+448 -1

No files matched your search

@@ -577,8 +577,9 @@
SelectedItem="{Binding #Board.((vm:VaultViewModel)DataContext).SelectedSidebarRow, Mode=OneWay}"
ToolTip.Tip="Double-press a card for a shell. Ctrl or Shift picks out several, and so does a band dragged across the space between them; right-click any of them for what can be done to the set.">
<!-- Builds only the cards near the view; see VirtualizingWrapPanel. -->
<ListBox.ItemsPanel>
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
<ItemsPanelTemplate><views:VirtualizingWrapPanel /></ItemsPanelTemplate>
</ListBox.ItemsPanel>
<!--
@@ -0,0 +1,383 @@
using System.Collections.Specialized;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Layout;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// A wrap panel that builds only the items near the part of it on screen.
/// </summary>
/// <remarks>
/// <para>
/// The hosts board is a list per section, each laid out in rows of cards. It was a plain
/// <see cref="WrapPanel"/>, which builds, measures and draws every card on the board whether it is in view or
/// not — some twenty-five controls a card, so a keychain of three hundred hosts took seconds to open and to
/// redraw after a filter was cleared, and a thousand took several times that. Avalonia has no virtualising
/// wrap panel of its own; this is one, for items of a fixed width.
/// </para>
/// <para>
/// <b>Widths are the first item's; heights are per row.</b> A card's width is set by its style, so the
/// number of columns is arithmetic. Its height is not quite — a card wearing many tags grows a line — so each
/// row's height is the tallest card built in it, and a row not yet built is assumed to be as tall as the last
/// one measured. When a row turns out different the rows below it move, which is the ordinary price of
/// estimating, and is only ever seen while scrolling into rows nobody has looked at yet.
/// </para>
/// <para>
/// <b>What is on screen is read from the effective viewport</b>, which is the part of this panel the
/// scroller around the whole board shows — the section's own list never scrolls, so its own scroller has
/// nothing to say. A section scrolled out of view builds nothing at all past the one card measured for size.
/// </para>
/// <para>
/// Everything else a list does is left to the list. Containers come from its generator and are prepared by
/// it, so selection, the card template, the context menu and the band all work on the containers that exist.
/// A container holding the keyboard is never put away while it does, so arrowing past the edge of the view
/// does not drop the focus on the floor.
/// </para>
/// </remarks>
internal sealed class VirtualizingWrapPanel : VirtualizingPanel
{
/// <summary>How far past the visible part, above and below, items are built ahead of being scrolled to.</summary>
private const double Overscan = 240;
/// <summary>What a row is assumed to be before anything in the panel has been measured.</summary>
private const double FallbackRowHeight = 124;
private readonly Dictionary<int, Control> realized = [];
private readonly Dictionary<object, Stack<Control>> recycled = [];
/// <summary>The pool each container this panel made goes back to; items that are their own containers are not in it.</summary>
private readonly Dictionary<Control, object> poolOf = [];
private readonly Dictionary<int, double> rowHeights = [];
private Rect viewport;
private bool viewportKnown;
private Size itemSize;
private int columns = 1;
/// <summary>An item asked to be scrolled to, kept built until a measure has seen it in range.</summary>
private int? pinned;
public VirtualizingWrapPanel()
{
EffectiveViewportChanged += (_, e) =>
{
if (viewportKnown && e.EffectiveViewport == viewport)
{
return;
}
viewport = e.EffectiveViewport;
viewportKnown = true;
InvalidateMeasure();
};
}
/// <inheritdoc />
protected override Size MeasureOverride(Size availableSize)
{
var items = Items;
if (items.Count == 0)
{
RecycleAllExcept(static _ => false);
return default;
}
if (itemSize == default)
{
var first = RealizedOrNew(0);
first.Measure(Size.Infinity);
itemSize = first.DesiredSize;
}
var width = double.IsInfinity(availableSize.Width)
? itemSize.Width * items.Count
: availableSize.Width;
var nowColumns = Math.Max(1, (int)Math.Floor(width / Math.Max(1, itemSize.Width)));
if (nowColumns != columns)
{
columns = nowColumns;
rowHeights.Clear();
}
var tops = RowTops(items.Count);
var (firstIndex, lastIndex) = VisibleRange(tops, items.Count);
RecycleAllExcept(index =>
(index >= firstIndex && index <= lastIndex)
|| index == pinned
|| realized[index].IsKeyboardFocusWithin);
for (var index = firstIndex; index <= lastIndex; index++)
{
var container = RealizedOrNew(index);
container.Measure(new Size(itemSize.Width, double.PositiveInfinity));
var row = index / columns;
rowHeights[row] = Math.Max(rowHeights.GetValueOrDefault(row), container.DesiredSize.Height);
}
if (pinned is { } kept && kept >= firstIndex && kept <= lastIndex)
{
pinned = null;
}
tops = RowTops(items.Count);
return new Size(Math.Min(width, columns * itemSize.Width), tops[^1]);
}
/// <inheritdoc />
protected override Size ArrangeOverride(Size finalSize)
{
var tops = RowTops(Items.Count);
foreach (var (index, container) in realized)
{
container.Arrange(ItemRect(index, tops));
}
return finalSize;
}
/// <inheritdoc />
protected override void OnItemsChanged(IReadOnlyList<object?> items, NotifyCollectionChangedEventArgs e)
{
base.OnItemsChanged(items, e);
// Every change is answered as a reset. The board swaps its lists in whole, so an incremental answer
// would be code for a case that does not happen; and indexes, row heights and the pinned item all
// describe a list that is no longer the one being shown.
RecycleAllExcept(static _ => false);
rowHeights.Clear();
pinned = null;
InvalidateMeasure();
}
/// <inheritdoc />
protected override Control? ScrollIntoView(int index)
{
if (index < 0 || index >= Items.Count)
{
return null;
}
var container = RealizedOrNew(index);
container.Measure(new Size(itemSize.Width, double.PositiveInfinity));
container.Arrange(ItemRect(index, RowTops(Items.Count)));
pinned = index;
container.BringIntoView();
return container;
}
/// <inheritdoc />
protected override Control? ContainerFromIndex(int index) => realized.GetValueOrDefault(index);
/// <inheritdoc />
protected override int IndexFromContainer(Control container)
{
foreach (var (index, held) in realized)
{
if (ReferenceEquals(held, container))
{
return index;
}
}
return -1;
}
/// <inheritdoc />
protected override IEnumerable<Control>? GetRealizedContainers() => realized.Values;
/// <inheritdoc />
/// <remarks>
/// Left and right step one card, up and down one row of them — the movements the plain wrap panel's own
/// navigation offered, so the board's arrows do what they did, with the card stepped to built on demand.
/// </remarks>
protected override IInputElement? GetControl(NavigationDirection direction, IInputElement? from, bool wrap)
{
var count = Items.Count;
if (count == 0)
{
return null;
}
var current = from is Control control ? IndexFromContainer(control) : -1;
var target = direction switch
{
NavigationDirection.First => 0,
NavigationDirection.Last => count - 1,
NavigationDirection.Left or NavigationDirection.Previous => current - 1,
NavigationDirection.Right or NavigationDirection.Next => current + 1,
NavigationDirection.Up => current - columns,
NavigationDirection.Down => current + columns,
_ => -1,
};
if (current < 0 && direction is not (NavigationDirection.First or NavigationDirection.Last))
{
target = 0;
}
if (target < 0 || target >= count)
{
if (!wrap)
{
return null;
}
target = (target % count + count) % count;
}
return ScrollIntoView(target);
}
/// <summary>The top of every row, and one more entry past the last row for the total height.</summary>
private double[] RowTops(int count)
{
var rows = (count + columns - 1) / columns;
var tops = new double[rows + 1];
var estimate = itemSize.Height > 0 ? itemSize.Height : FallbackRowHeight;
for (var row = 0; row < rows; row++)
{
tops[row + 1] = tops[row] + rowHeights.GetValueOrDefault(row, estimate);
}
return tops;
}
/// <summary>The first and last item in the rows that overlap the visible part, with the overscan.</summary>
private (int First, int Last) VisibleRange(double[] tops, int count)
{
// Before the first viewport arrives, a screenful from the top: the panel has to measure to something.
var top = viewportKnown ? viewport.Top - Overscan : 0;
var bottom = viewportKnown ? viewport.Bottom + Overscan : 1200;
var rows = tops.Length - 1;
var firstRow = -1;
var lastRow = -1;
for (var row = 0; row < rows; row++)
{
if (tops[row + 1] < top)
{
continue;
}
if (tops[row] > bottom)
{
break;
}
if (firstRow < 0)
{
firstRow = row;
}
lastRow = row;
}
return firstRow < 0
? (0, -1)
: (firstRow * columns, Math.Min(count - 1, ((lastRow + 1) * columns) - 1));
}
private Rect ItemRect(int index, double[] tops)
{
var row = index / columns;
var column = index % columns;
var height = row + 1 < tops.Length ? tops[row + 1] - tops[row] : itemSize.Height;
return new Rect(column * itemSize.Width, tops[Math.Min(row, tops.Length - 1)], itemSize.Width, height);
}
/// <summary>The container for an item, built or brought back from the pool if it is not already up.</summary>
private Control RealizedOrNew(int index)
{
if (realized.TryGetValue(index, out var existing))
{
return existing;
}
var generator = ItemContainerGenerator!;
var item = Items[index];
Control container;
if (generator.NeedsContainer(item, index, out var recycleKey))
{
var key = recycleKey ?? typeof(object);
if (recycled.TryGetValue(key, out var pool) && pool.TryPop(out var reused))
{
container = reused;
container.IsVisible = true;
}
else
{
container = generator.CreateContainer(item, index, recycleKey);
poolOf[container] = key;
AddInternalChild(container);
}
generator.PrepareItemContainer(container, item, index);
realized[index] = container;
generator.ItemContainerPrepared(container, item, index);
}
else
{
// An item that is its own container: built by whoever made the item, and never pooled.
container = (Control)item!;
AddInternalChild(container);
realized[index] = container;
}
return container;
}
/// <summary>Puts away every built container the predicate does not keep.</summary>
private void RecycleAllExcept(Func<int, bool> keep)
{
foreach (var index in realized.Keys.ToList())
{
if (keep(index))
{
continue;
}
var container = realized[index];
realized.Remove(index);
if (poolOf.TryGetValue(container, out var key))
{
ItemContainerGenerator!.ClearItemContainer(container);
container.IsVisible = false;
if (!recycled.TryGetValue(key, out var pool))
{
recycled[key] = pool = new Stack<Control>();
}
pool.Push(container);
}
else
{
RemoveInternalChild(container);
}
}
}
}
@@ -567,6 +567,69 @@ public sealed class HostGridTests : IAsyncLifetime
});
}
/// <remarks>
/// <para>
/// The board was a plain wrap panel, which built every card on it whether it was in view or not — some
/// twenty-five controls a card, so three hundred hosts took seconds to open and to redraw after a filter
/// was cleared. It builds the rows near the view now; see <c>VirtualizingWrapPanel</c>.
/// </para>
/// <para>
/// And the arrows still go everywhere, which is the half a virtualising panel can quietly break: Down
/// steps a whole row, and End reaches the last card although nothing had built it.
/// </para>
/// </remarks>
[Fact]
public async Task ADeepBoard_BuildsTheCardsInView_AndTheArrowsStillReachTheRest()
{
for (var host = 0; host < 120; host++)
{
vault.NewHostCommand.Execute(null);
vault.EditorLabel = $"zz-host-{host:000}";
vault.EditorHostname = $"zz-host-{host:000}.internal";
vault.EditorUsername = "deploy";
await vault.SaveHostCommand.ExecuteAsync(null);
}
await vault.LoadAsync(Token);
// Nothing selected, so the board opens at the top: a selected card is scrolled into view, and the last
// host saved is the one the editor left selected.
vault.SelectedHost = null;
await OnTheBoardAsync((screen, window) =>
{
var built = screen.GetVisualDescendants().OfType<ListBoxItem>().Count(card => card.IsVisible);
var total = vault.HostBoardOrder.Count();
total.ShouldBe(122);
built.ShouldBeGreaterThan(0);
built.ShouldBeLessThan(total / 2, "only the rows near the view are built");
var order = vault.HostBoardOrder.ToList();
var first = order[0];
Click(CardFor(screen, first), window);
vault.SelectedHost.ShouldBeSameAs(first);
var columns = CardFor(screen, first).GetVisualParent().ShouldBeOfType<VirtualizingWrapPanel>()
.GetVisualChildren().OfType<ListBoxItem>()
.Where(card => card.IsVisible)
.Count(card => Math.Abs(card.Bounds.Top - CardFor(screen, first).Bounds.Top) < 0.5);
window.KeyPressQwerty(PhysicalKey.ArrowDown, RawInputModifiers.None);
Dispatcher.UIThread.RunJobs();
vault.SelectedHost.ShouldBeSameAs(order[columns], "Down steps one whole row of cards");
window.KeyPressQwerty(PhysicalKey.End, RawInputModifiers.None);
Dispatcher.UIThread.RunJobs();
vault.SelectedHost.ShouldBeSameAs(order[^1], "the last card, built on the way there");
});
}
// ---- Helpers ----
private static void RightClick(Visual card, Visual window)