Public Access
Desktop: build only the host cards near the view, with a virtualising wrap panel for the board
This commit is contained in:
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}"
|
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.">
|
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>
|
<ListBox.ItemsPanel>
|
||||||
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
|
<ItemsPanelTemplate><views:VirtualizingWrapPanel /></ItemsPanelTemplate>
|
||||||
</ListBox.ItemsPanel>
|
</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 ----
|
// ---- Helpers ----
|
||||||
|
|
||||||
private static void RightClick(Visual card, Visual window)
|
private static void RightClick(Visual card, Visual window)
|
||||||
|
|||||||
Reference in new issue
Block a user