Zum Inhalt
Kuonen.UI

DataGrid

Eine Tabelle für sehr viele Zeilen: Sie legt nur die sichtbaren Zeilen an, darum kostet eine Million Zeilen nicht mehr als zehn. Oben liefert die Quelle die Zeilen aus dem Index und sortiert wie eine Datenbank (Kopf klicken, Umschalt für eine zweite Stufe); F2, Tippen oder Doppelklick bearbeitet eine Zelle, die Leertaste schaltet das Kästchen. Unten kommen die Seiten einer asynchronen Quelle verzögert an – bis dahin zeigt die Tabelle Platzhalter.

Vorführung: DataGridVorführung: DataGrid

KXAML

DataGridSeite.kxaml
<StackPanel xmlns="https://ui.kuonen.org/kxaml"
            xmlns:x="https://ui.kuonen.org/kxaml/x"
            xmlns:local="using:Kuonen.UI.Galerie.Seiten"
            x:Class="Kuonen.UI.Galerie.Seiten.DataGridVorfuehrung"
            Spacing="12">
  <!-- Eine Million Zeilen: Die Quelle (KundenQuelle, im Code-Behind gesetzt) berechnet jede Zeile aus ihrem Index. -->
  <TextBlock Text="Eine Million Zeilen" FontWeight="SemiBold" />
  <DataGrid x:Name="Gitter" Width="640" Height="260"
            SelectionChanged="Gitter_SelectionChanged" BeginningEdit="Gitter_BeginningEdit">
    <DataGridTextColumn x:DataType="local:Kunde" Header="Nr." Width="80" SortMemberPath="Nr"
                        Binding="{Binding Nr, Mode=OneWay}" />
    <DataGridTextColumn x:DataType="local:Kunde" Header="Name" Width="*" SortMemberPath="Name"
                        Binding="{Binding Name}" />
    <DataGridTextColumn x:DataType="local:Kunde" Header="Ort" Width="120" SortMemberPath="Ort"
                        Binding="{Binding Ort}" />
    <DataGridTextColumn x:DataType="local:Kunde" Header="Betrag" Width="100" SortMemberPath="Betrag" Format="N2"
                        Binding="{Binding Betrag}" />
    <DataGridCheckBoxColumn x:DataType="local:Kunde" Header="Aktiv" Width="70" SortMemberPath="Aktiv"
                            Binding="{Binding Aktiv}" />
  </DataGrid>
  <StackPanel Orientation="Horizontal" Spacing="8">
    <Button Content="Zum Ende" Click="Ende_Click" />
    <Button Content="Alle auswählen" Click="Alle_Click" />
    <Button Content="Aufheben" Click="Keine_Click" />
  </StackPanel>
  <TextBlock x:Name="Meldung" Text="Nichts gewählt." />
  <!-- Asynchrone Quelle: Die Seiten kommen verzögert (am Bildtakt des Fensters); bis dahin stehen Platzhalter da. -->
  <TextBlock Text="Asynchrone Quelle mit Vorlagenspalte" FontWeight="SemiBold" />
  <DataGrid x:Name="Asynchron" Width="640" Height="180" CanUserSortColumns="False">
    <DataGridTextColumn x:DataType="local:Kunde" Header="Nr." Width="80"
                        Binding="{Binding Nr, Mode=OneWay}" />
    <DataGridTemplateColumn Header="Person" Width="*">
      <DataGridTemplateColumn.CellTemplate>
        <DataTemplate x:DataType="local:Kunde">
          <StackPanel Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
            <TextBlock Text="{Binding Name}" FontWeight="SemiBold" />
            <TextBlock Text="{Binding Ort}" />
          </StackPanel>
        </DataTemplate>
      </DataGridTemplateColumn.CellTemplate>
    </DataGridTemplateColumn>
    <DataGridTextColumn x:DataType="local:Kunde" Header="Betrag" Width="100" Format="N2"
                        Binding="{Binding Betrag, Mode=OneWay}" />
    <DataGridCheckBoxColumn x:DataType="local:Kunde" Header="Aktiv" Width="70"
                            Binding="{Binding Aktiv, Mode=OneWay}" />
  </DataGrid>
</StackPanel>
DataGridSeite.kxaml.cs
using Kuonen.UI;
using Kuonen.UI.Controls;
using Kuonen.UI.Ereignisse;

namespace Kuonen.UI.Galerie.Seiten;

public sealed partial class DataGridSeite
{
    protected override Func<UIElement>? KxamlFabrik => () => new DataGridVorfuehrung();
}

/// <summary>KXAML-Fassung der DataGrid-Seite: der Baum steht in DataGridSeite.kxaml, hier die Quellen und das Verhalten.</summary>
public sealed partial class DataGridVorfuehrung
{
    public DataGridVorfuehrung()
    {
        InitializeComponent();
        Gitter.Datenquelle = new KundenQuelle();
        Asynchron.Datenquelle = Zeilenquelle.Aus(new VerzoegerteKunden(Asynchron));
    }

    private void Gitter_SelectionChanged(object sender, DataGridSelectionChangedEventArgs e) =>
        Meldung.Text = DataGridSeite.MeldungVon(Gitter.Auswahl.Anzahl);

    // Eine Zeile, die der Benutzer bearbeitet, behält die Quelle (die übrigen berechnet sie bei Bedarf neu).
    private void Gitter_BeginningEdit(object sender, DataGridBeginningEditEventArgs e) =>
        e.Cancel = !((KundenQuelle)Gitter.Datenquelle!).Behalte(e.Zeile);

    private void Ende_Click(object sender, RoutedEventArgs e) => Gitter.ScrollIntoView(Gitter.ZeilenAnzahl - 1);

    private void Alle_Click(object sender, RoutedEventArgs e) => Gitter.SelectAll();

    private void Keine_Click(object sender, RoutedEventArgs e) => Gitter.UnselectAll();
}

C#

DataGridSeite.cs
using System.ComponentModel;
using System.Globalization;
using Kuonen.UI;
using Kuonen.UI.Bindung;
using Kuonen.UI.Controls;
using Kuonen.UI.Eingabe;
using Kuonen.UI.Layout;

namespace Kuonen.UI.Galerie.Seiten;

/// <summary>Galerie-Seite: <see cref="DataGrid"/>.</summary>
public sealed partial class DataGridSeite : GalerieSeite
{
    public override string Titel => "DataGrid";

    public override string Beschreibung =>
        "Eine Tabelle für sehr viele Zeilen: Sie legt nur die sichtbaren Zeilen an, darum kostet eine Million Zeilen nicht mehr "
        + "als zehn. Oben liefert die Quelle die Zeilen aus dem Index und sortiert wie eine Datenbank (Kopf klicken, Umschalt für "
        + "eine zweite Stufe); F2, Tippen oder Doppelklick bearbeitet eine Zelle, die Leertaste schaltet das Kästchen. Unten "
        + "kommen die Seiten einer asynchronen Quelle verzögert an – bis dahin zeigt die Tabelle Platzhalter.";

    public override string Stichworte =>
        "Tabelle Grid Spalten Zeilen Sortieren Bearbeiten Virtualisierung Datenquelle asynchron Platzhalter Vorlagenspalte Kontrollkästchen";

    public override string Datei => nameof(DataGridSeite);

    public override UIElement Baue()
    {
        // Eine Million Zeilen: Die Quelle berechnet jede Zeile aus ihrem Index.
        var meldung = new TextBlock { Text = "Nichts gewählt." };
        var gitter = new DataGrid { Width = 640, Height = 260, Datenquelle = new KundenQuelle() };
        gitter.Columns.Add(new DataGridTextColumn
        {
            Header = "Nr.", Width = GridLength.Pixel(80), SortMemberPath = "Nr",
            Binding = Zellbindung.Aus(BindingPath.Stufe<Kunde, int>("Nr", static k => k.Nr), schreibbar: false),
        });
        gitter.Columns.Add(new DataGridTextColumn
        {
            Header = "Name", Width = GridLength.Star(), SortMemberPath = "Name",
            Binding = Zellbindung.Aus(BindingPath.Stufe<Kunde, string>("Name", static k => k.Name, static (k, w) => k.Name = w)),
        });
        gitter.Columns.Add(new DataGridTextColumn
        {
            Header = "Ort", Width = GridLength.Pixel(120), SortMemberPath = "Ort",
            Binding = Zellbindung.Aus(BindingPath.Stufe<Kunde, string>("Ort", static k => k.Ort, static (k, w) => k.Ort = w)),
        });
        gitter.Columns.Add(new DataGridTextColumn
        {
            Header = "Betrag", Width = GridLength.Pixel(100), SortMemberPath = "Betrag", Format = "N2",
            Binding = Zellbindung.Aus(BindingPath.Stufe<Kunde, double>("Betrag", static k => k.Betrag, static (k, w) => k.Betrag = w)),
        });
        gitter.Columns.Add(new DataGridCheckBoxColumn
        {
            Header = "Aktiv", Width = GridLength.Pixel(70), SortMemberPath = "Aktiv",
            Binding = Zellbindung.Aus(BindingPath.Stufe<Kunde, bool>("Aktiv", static k => k.Aktiv, static (k, w) => k.Aktiv = w)),
        });
        gitter.SelectionChanged += (_, _) => meldung.Text = MeldungVon(gitter.Auswahl.Anzahl);
        // Eine Zeile, die der Benutzer bearbeitet, behält die Quelle (die übrigen berechnet sie bei Bedarf neu).
        gitter.BeginningEdit += (_, e) => e.Cancel = !((KundenQuelle)gitter.Datenquelle!).Behalte(e.Zeile);

        var ende = new Button { Content = "Zum Ende" };
        ende.Click += (_, _) => gitter.ScrollIntoView(gitter.ZeilenAnzahl - 1);
        var alle = new Button { Content = "Alle auswählen" };
        alle.Click += (_, _) => gitter.SelectAll();
        var keine = new Button { Content = "Aufheben" };
        keine.Click += (_, _) => gitter.UnselectAll();
        var knoepfe = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 };
        knoepfe.Children.Add(ende);
        knoepfe.Children.Add(alle);
        knoepfe.Children.Add(keine);

        // Asynchrone Quelle: Die Seiten kommen verzögert (am Bildtakt des Fensters); bis dahin stehen Platzhalter da.
        var asynchron = new DataGrid { Width = 640, Height = 180, CanUserSortColumns = false };
        asynchron.Datenquelle = Zeilenquelle.Aus(new VerzoegerteKunden(asynchron));
        asynchron.Columns.Add(new DataGridTextColumn
        {
            Header = "Nr.", Width = GridLength.Pixel(80),
            Binding = Zellbindung.Aus(BindingPath.Stufe<Kunde, int>("Nr", static k => k.Nr), schreibbar: false),
        });
        asynchron.Columns.Add(new DataGridTemplateColumn { Header = "Person", Width = GridLength.Star(), CellTemplate = new DataTemplate(static _ => Person()) });
        asynchron.Columns.Add(new DataGridTextColumn
        {
            Header = "Betrag", Width = GridLength.Pixel(100), Format = "N2",
            Binding = Zellbindung.Aus(BindingPath.Stufe<Kunde, double>("Betrag", static k => k.Betrag), schreibbar: false),
        });
        asynchron.Columns.Add(new DataGridCheckBoxColumn
        {
            Header = "Aktiv", Width = GridLength.Pixel(70),
            Binding = Zellbindung.Aus(BindingPath.Stufe<Kunde, bool>("Aktiv", static k => k.Aktiv), schreibbar: false),
        });

        var seite = new StackPanel { Spacing = 12 };
        seite.Children.Add(new TextBlock { Text = "Eine Million Zeilen", FontWeight = 600 });
        seite.Children.Add(gitter);
        seite.Children.Add(knoepfe);
        seite.Children.Add(meldung);
        seite.Children.Add(new TextBlock { Text = "Asynchrone Quelle mit Vorlagenspalte", FontWeight = 600 });
        seite.Children.Add(asynchron);
        return seite;
    }

    // Die Zelle der Vorlagenspalte: der Name fett, der Ort daneben; beide an die Zeile gebunden. (Opacity unter 1 wäre möglich: der Rasterisierer holt
    // die Ebene einer Gruppe erst beim ersten Pixel im Schmutzbereich und schneidet sie auf den Beschnitt zu.)
    private static StackPanel Person()
    {
        var name = new TextBlock { FontWeight = 600 };
        name.SetBinding(TextBlock.TextProperty, new Binding<Kunde, string>(BindingPath.Stufe<Kunde, string>("Name", static k => k.Name)));
        var ort = new TextBlock();
        ort.SetBinding(TextBlock.TextProperty, new Binding<Kunde, string>(BindingPath.Stufe<Kunde, string>("Ort", static k => k.Ort)));
        var zelle = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6, VerticalAlignment = VerticalAlignment.Center };
        zelle.Children.Add(name);
        zelle.Children.Add(ort);
        return zelle;
    }

    // Feste Kultur: die Meldung soll nicht vom Rechner des Benutzers abhängen.
    internal static string MeldungVon(int anzahl) => anzahl switch
    {
        0 => "Nichts gewählt.",
        1 => "1 Zeile gewählt.",
        var n => $"{n.ToString("N0", CultureInfo.InvariantCulture)} Zeilen gewählt.",
    };
}

/// <summary>Eine Zeile der Vorführung. Die Tabelle liest und schreibt sie über <see cref="Zellbindung"/>, ohne Reflexion.</summary>
public sealed class Kunde
{
    public int Nr { get; set; }

    public string Name { get; set; } = "";

    public string Ort { get; set; } = "";

    public double Betrag { get; set; }

    public bool Aktiv { get; set; }
}

/// <summary>
/// Eine Million <see cref="Kunde"/> ohne eine Million Objekte: Name, Ort, Betrag und Kästchen sind Funktionen des Index. Die Quelle
/// hält nur einen Vorrat von <see cref="PoolGroesse"/> wiederverwendeten Zeilen (Zeile <c>i</c> liegt auf Platz <c>i</c> mod
/// <see cref="PoolGroesse"/> und wird dort neu berechnet, wenn der Platz einer anderen Zeile gehörte): Rollen belegt nichts.
/// Eine Zeile, die der Benutzer bearbeitet, wandert mit <see cref="Behalte"/> aus dem Vorrat in eine eigene Liste (höchstens
/// <see cref="MaxBehalten"/>) und behält ihre Werte. Sortiert wird wie in einer Datenbank an der Quelle (<c>ORDER BY</c>): Weil jede
/// Spalte nur 2 bis 1000 verschiedene Werte hat, die gleich oft vorkommen, lässt sich die Zeile an jeder Stelle der sortierten
/// Folge ohne Sortierlauf berechnen.
/// </summary>
public sealed class KundenQuelle : Zeilenquelle, ISortierbareQuelle
{
    /// <summary>Zahl der Zeilen (teilbar durch 1000, damit jede Spalte gleich grosse Gruppen hat).</summary>
    public const int ZeilenGesamt = 1_000_000;

    /// <summary>Zahl der wiederverwendeten Zeilen; mehr als je gleichzeitig im Bild sind (Zweierpotenz).</summary>
    public const int PoolGroesse = 4096;

    /// <summary>Höchstzahl der bearbeiteten Zeilen, die die Quelle hält; weiter wird das Bearbeiten verweigert.</summary>
    public const int MaxBehalten = 10_000;

    /// <summary>1000 Namen (20 Vornamen mal 50 Nachnamen).</summary>
    public static readonly string[] Namen = BaueNamen();

    /// <summary>20 Orte.</summary>
    public static readonly string[] Orte =
    [
        "Aarau", "Basel", "Bellinzona", "Bern", "Biel", "Chur", "Davos", "Freiburg", "Genf", "Glarus",
        "Lausanne", "Luzern", "Neuenburg", "Olten", "Sitten", "Solothurn", "St. Gallen", "Thun", "Winterthur", "Zürich",
    ];

    private const int Reste = 1000;

    private enum Ordnung : byte { Natuerlich, NrAbsteigend, Gruppen }

    private readonly Kunde?[] _vorrat = new Kunde?[PoolGroesse];
    private readonly Dictionary<int, Kunde> _behalten = [];
    private Ordnung _ordnung;
    private bool _nrAbsteigend;
    private int[] _rest = [], _anfang = [], _laenge = [];

    /// <summary>
    /// Wie viele <see cref="Kunde"/>-Objekte die Quelle angelegt hat: der Vorrat gleich beim Anlegen (damit der Bildlauf nie ein Objekt
    /// anlegt), danach eines je gehaltener Zeile (<see cref="Behalte"/>).
    /// </summary>
    public int Angelegt { get; private set; }

    /// <summary>Legt die Quelle samt Vorrat an (rund 200 KB, einmal).</summary>
    public KundenQuelle()
    {
        for (int i = 0; i < _vorrat.Length; i++) _vorrat[i] = new Kunde();
        Angelegt = _vorrat.Length;
    }

    /// <summary>Wie oft eine Zeile des Vorrats neu berechnet wurde.</summary>
    public long Fuellungen { get; private set; }

    /// <summary>Wie viele bearbeitete Zeilen die Quelle hält.</summary>
    public int Behalten => _behalten.Count;

    public override int Anzahl => ZeilenGesamt;

    public override ISortierbareQuelle? Sortierbar => this;

    /// <summary>Der Betrag der Zeile mit dem Rest <paramref name="rest"/> (Zeile mod 1000): 10,00 bis 259,75, jeder Wert einmal je Rest.</summary>
    public static double BetragVon(int rest) => (((rest % 1000) * 7 % 1000) / 4.0) + 10.0;

    /// <summary>Ob die Zeile mit dem Rest <paramref name="rest"/> (Zeile mod 2) aktiv ist.</summary>
    public static bool AktivVon(int rest) => rest % 2 == 0;

    /// <summary>Berechnet die Werte der Zeile <paramref name="zeile"/> (0 bis <see cref="ZeilenGesamt"/> − 1) in <paramref name="kunde"/>; belegt nichts.</summary>
    public static void Fuelle(Kunde kunde, int zeile)
    {
        kunde.Nr = zeile + 1;
        kunde.Name = Namen[zeile % Namen.Length];
        kunde.Ort = Orte[zeile % Orte.Length];
        kunde.Betrag = BetragVon(zeile % 1000);
        kunde.Aktiv = AktivVon(zeile % 2);
    }

    public override ZeilenZustand Zustand(int index, out object? zeile)
    {
        zeile = null;
        if ((uint)index >= ZeilenGesamt) return ZeilenZustand.Fehlt;
        int nr = ZeileVon(index);
        if (_behalten.Count > 0 && _behalten.TryGetValue(nr, out var gehalten))
        {
            zeile = gehalten;
            return ZeilenZustand.Geladen;
        }
        ref var platz = ref _vorrat[index & (PoolGroesse - 1)];
        if (platz is null)
        {
            platz = new Kunde();
            Angelegt++;
        }
        if (platz.Nr != nr + 1)
        {
            Fuelle(platz, nr);
            Fuellungen++;
        }
        zeile = platz;
        return ZeilenZustand.Geladen;
    }

    /// <summary>
    /// Hält die Zeile an Stelle <paramref name="index"/> der Anzeige fest, damit eine Bearbeitung nicht verloren geht, wenn der Vorrat den
    /// Platz neu belegt. Wahr, wenn die Zeile gehalten wird (auch schon vorher); falsch ausserhalb der Zeilen und über <see cref="MaxBehalten"/>.
    /// </summary>
    public bool Behalte(int index)
    {
        if ((uint)index >= ZeilenGesamt) return false;
        int nr = ZeileVon(index);
        if (_behalten.ContainsKey(nr)) return true;
        if (_behalten.Count >= MaxBehalten) return false;
        Zustand(index, out var zeile);
        _behalten[nr] = (Kunde)zeile!;
        _vorrat[index & (PoolGroesse - 1)] = null; // das Objekt gehört jetzt der Liste; der Platz bekommt bei Bedarf ein neues
        return true;
    }

    /// <summary>
    /// Sortiert nach allen Stufen (Nr, Name, Ort, Betrag, Aktiv), die erste zuerst; Nr gilt als letzte Stufe (eine Stufe Nr beendet die
    /// Folge, spätere entfallen, denn Nr ist eindeutig), ohne Nr ordnet die Nummer aufsteigend. Ohne Stufen gilt die natürliche Ordnung.
    /// Ein unbekannter Schlüssel lehnt alles ab (<c>false</c>, nichts ändert sich). Name, Ort, Betrag und Aktiv sind Funktionen des Rests
    /// der Zeilennummer mod 1000; die Stufen vor Nr ordnen darum die 1000 Reste (Rang, Ränder gleicher Werte), und die Zeilen eines Rests sind
    /// <c>r, r + 1000, r + 2000 …</c>. So lässt sich die Zeile an jeder Stelle der Folge ohne Sortierlauf und ohne Belegung berechnen.
    /// Ein bearbeiteter Wert ändert die Reihenfolge nicht: Die Quelle ordnet nach den berechneten Werten.
    /// </summary>
    bool ISortierbareQuelle.Sortiere(IReadOnlyList<Sortierung> sortierung)
    {
        if (sortierung is null) return false;
        // Zuerst prüfen: nur bekannte Schlüssel, höchstens bis zur Stufe Nr.
        int stufen = 0;
        foreach (var stufe in sortierung)
        {
            if (stufe.Schluessel is not ("Nr" or "Name" or "Ort" or "Betrag" or "Aktiv")) return false;
            stufen++;
            if (stufe.Schluessel == "Nr") break;
        }
        if (stufen == 0)
        {
            _ordnung = Ordnung.Natuerlich;
        }
        else if (sortierung[0].Schluessel == "Nr")
        {
            _ordnung = sortierung[0].Richtung == ListSortDirection.Descending ? Ordnung.NrAbsteigend : Ordnung.Natuerlich;
        }
        else
        {
            bool nrAbsteigend = sortierung[stufen - 1].Schluessel == "Nr" && sortierung[stufen - 1].Richtung == ListSortDirection.Descending;
            if (sortierung[stufen - 1].Schluessel == "Nr") stufen--;
            var rest = new int[Reste];
            for (int i = 0; i < rest.Length; i++) rest[i] = i;
            int Vergleiche(int a, int b)
            {
                for (int s = 0; s < stufen; s++)
                {
                    int c = sortierung[s].Schluessel switch
                    {
                        "Name" => string.CompareOrdinal(Namen[a], Namen[b]),
                        "Ort" => string.CompareOrdinal(Orte[a % Orte.Length], Orte[b % Orte.Length]),
                        "Betrag" => BetragVon(a).CompareTo(BetragVon(b)),
                        _ => AktivVon(a % 2).CompareTo(AktivVon(b % 2)),
                    };
                    if (c != 0) return sortierung[s].Richtung == ListSortDirection.Descending ? -c : c;
                }
                return 0;
            }
            // Gleiche Werte: die kleinere Nummer zuerst (die Reste sind dann in Reihenfolge der Nummern).
            Array.Sort(rest, (a, b) =>
            {
                int c = Vergleiche(a, b);
                return c != 0 ? c : a.CompareTo(b);
            });
            var anfang = new int[Reste];
            var laenge = new int[Reste];
            for (int i = 0; i < Reste;)
            {
                int j = i + 1;
                while (j < Reste && Vergleiche(rest[i], rest[j]) == 0) j++;
                for (int k = i; k < j; k++) (anfang[k], laenge[k]) = (i, j - i);
                i = j;
            }
            (_rest, _anfang, _laenge, _nrAbsteigend) = (rest, anfang, laenge, nrAbsteigend);
            _ordnung = Ordnung.Gruppen;
        }
        MeldeAenderung(new ZeilenAenderung(ZeilenAenderungsArt.Neu));
        return true;
    }

    // Die Nummer (0-basiert) der Zeile, die an Stelle k der sortierten Folge steht. Jeder der 1000 Reste hat 1000 Zeilen; gleiche Werte
    // bilden einen Lauf von Resten (Anfang, Länge), dessen Zeilen nach Nummer geordnet stehen: Zeile j des Laufs ist Rest (j mod Länge) mal Umlauf (j div Länge).
    private int ZeileVon(int k)
    {
        switch (_ordnung)
        {
            case Ordnung.NrAbsteigend:
                return ZeilenGesamt - 1 - k;
            case Ordnung.Gruppen:
                const int proRest = ZeilenGesamt / Reste;
                int platz = k / proRest;
                int anfang = _anfang[platz], laenge = _laenge[platz];
                int j = k - (anfang * proRest);
                if (_nrAbsteigend) j = (laenge * proRest) - 1 - j;
                return _rest[anfang + (j % laenge)] + (Reste * (j / laenge));
            default:
                return k;
        }
    }

    private static string[] BaueNamen()
    {
        string[] vornamen =
        [
            "Anna", "Beat", "Clara", "Daniel", "Elena", "Felix", "Greta", "Hans", "Irene", "Jonas",
            "Karin", "Lukas", "Maria", "Nico", "Olivia", "Peter", "Rita", "Simon", "Tanja", "Urs",
        ];
        string[] nachnamen =
        [
            "Aebi", "Bachmann", "Baumann", "Berger", "Brunner", "Burri", "Camenzind", "Frei", "Fuchs", "Gerber",
            "Graf", "Gut", "Hofer", "Huber", "Imhof", "Jost", "Keller", "Kuonen", "Lehmann", "Meier",
            "Meyer", "Moser", "Müller", "Näf", "Odermatt", "Pfister", "Rey", "Roth", "Schmid", "Schneider",
            "Steiner", "Studer", "Suter", "Tanner", "Vogel", "Wagner", "Walter", "Weber", "Wyss", "Zbinden",
            "Zürcher", "Amstutz", "Bieri", "Christen", "Egger", "Furrer", "Gasser", "Haas", "Iten", "Lüthi",
        ];
        var namen = new string[vornamen.Length * nachnamen.Length];
        for (int i = 0; i < namen.Length; i++) namen[i] = vornamen[i % vornamen.Length] + " " + nachnamen[i / vornamen.Length];
        return namen;
    }
}

/// <summary>
/// Eine asynchrone Quelle: <see cref="LadeAsync"/> liefert jede Seite erst <see cref="VerzoegerungMs"/> Millisekunden später, gezählt am
/// Bildtakt des Fensters (die Zeit kommt vom Host, nicht von einem Zeitgeber). Bis dahin zeigt das <see cref="DataGrid"/> Platzhalter.
/// Steht der Besitzer in keinem Fenster, kommt die Seite sofort.
/// </summary>
public sealed class VerzoegerteKunden(UIElement besitzer) : IVirtualDataSource<Kunde>, ITaktTeilnehmer
{
    /// <summary>Wartezeit einer Seite in Millisekunden (Zeit des Hosts).</summary>
    public const int VerzoegerungMs = 400;

    /// <summary>Zahl der Zeilen.</summary>
    public const int Zeilen = 100_000;

    private readonly List<Auftrag> _offen = [];

    public DatenAnzahl Anzahl => DatenAnzahl.Bekannt(Zeilen);

    public ValueTask<Seite<Kunde>> LadeAsync(int start, int anzahl, CancellationToken ct)
    {
        int von = Math.Clamp(start, 0, Zeilen);
        var kunden = new Kunde[Math.Clamp(anzahl, 0, Zeilen - von)];
        for (int i = 0; i < kunden.Length; i++)
        {
            kunden[i] = new Kunde();
            KundenQuelle.Fuelle(kunden[i], von + i);
        }
        var seite = new Seite<Kunde>(kunden, IstEnde: von + kunden.Length >= Zeilen);
        if (EingabeVerwaltung.Von(besitzer) is not { } verwaltung) return new ValueTask<Seite<Kunde>>(seite);
        var auftrag = new Auftrag(seite, ct);
        _offen.Add(auftrag);
        verwaltung.MeldeAnimation(this);
        return new ValueTask<Seite<Kunde>>(auftrag.Ergebnis.Task);
    }

    /// <summary>Ein Schritt am Bildtakt: liefert, was fällig ist; läuft weiter, solange etwas wartet.</summary>
    public bool Takt(long zeit)
    {
        for (int i = 0; i < _offen.Count;)
        {
            var auftrag = _offen[i];
            if (auftrag.Abbruch.IsCancellationRequested)
            {
                _offen.RemoveAt(i);
                auftrag.Ergebnis.TrySetCanceled(auftrag.Abbruch);
                continue;
            }
            if (auftrag.Faellig < 0) auftrag.Faellig = zeit + VerzoegerungMs;
            if (zeit >= auftrag.Faellig)
            {
                _offen.RemoveAt(i);
                auftrag.Ergebnis.TrySetResult(auftrag.Seite);
                continue;
            }
            i++;
        }
        return _offen.Count > 0;
    }

    private sealed class Auftrag(Seite<Kunde> seite, CancellationToken abbruch)
    {
        public Seite<Kunde> Seite { get; } = seite;

        public CancellationToken Abbruch { get; } = abbruch;

        public long Faellig { get; set; } = -1;

        public TaskCompletionSource<Seite<Kunde>> Ergebnis { get; } = new();
    }
}