using System.Collections.Specialized; using Avalonia; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Layout; namespace DodoSSH.Client.App.Views; /// /// A wrap panel that builds only the items near the part of it on screen. /// /// /// /// The hosts board is a list per section, each laid out in rows of cards. It was a plain /// , 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. /// /// /// Widths are the first item's; heights are per row. 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. /// /// /// What is on screen is read from the effective viewport, 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. /// /// /// 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. /// /// internal sealed class VirtualizingWrapPanel : VirtualizingPanel { /// How far past the visible part, above and below, items are built ahead of being scrolled to. private const double Overscan = 240; /// What a row is assumed to be before anything in the panel has been measured. private const double FallbackRowHeight = 124; private readonly Dictionary realized = []; private readonly Dictionary> recycled = []; /// The pool each container this panel made goes back to; items that are their own containers are not in it. private readonly Dictionary poolOf = []; private readonly Dictionary rowHeights = []; private Rect viewport; private bool viewportKnown; private Size itemSize; private int columns = 1; /// An item asked to be scrolled to, kept built until a measure has seen it in range. private int? pinned; public VirtualizingWrapPanel() { EffectiveViewportChanged += (_, e) => { if (viewportKnown && e.EffectiveViewport == viewport) { return; } viewport = e.EffectiveViewport; viewportKnown = true; InvalidateMeasure(); }; } /// 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]); } /// protected override Size ArrangeOverride(Size finalSize) { var tops = RowTops(Items.Count); foreach (var (index, container) in realized) { container.Arrange(ItemRect(index, tops)); } return finalSize; } /// protected override void OnItemsChanged(IReadOnlyList 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(); } /// 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; } /// protected override Control? ContainerFromIndex(int index) => realized.GetValueOrDefault(index); /// protected override int IndexFromContainer(Control container) { foreach (var (index, held) in realized) { if (ReferenceEquals(held, container)) { return index; } } return -1; } /// protected override IEnumerable? GetRealizedContainers() => realized.Values; /// /// /// 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. /// 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); } /// The top of every row, and one more entry past the last row for the total height. 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; } /// The first and last item in the rows that overlap the visible part, with the overscan. 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); } /// The container for an item, built or brought back from the pool if it is not already up. 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; } /// Puts away every built container the predicate does not keep. private void RecycleAllExcept(Func 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(); } pool.Push(container); } else { RemoveInternalChild(container); } } } }