diff --git a/samples/ControlCatalog/Converter/MarkdownToInlinesConverter.cs b/samples/ControlCatalog/Converter/MarkdownToInlinesConverter.cs new file mode 100644 index 00000000000..2a13889107d --- /dev/null +++ b/samples/ControlCatalog/Converter/MarkdownToInlinesConverter.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Avalonia.Controls.Documents; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace ControlCatalog.Converter +{ + /// + /// Turns a small subset of markdown into s for a TextBlock: ATX headings, + /// bullet and numbered lists, **bold**, *italic* and `code`. + /// + /// Deliberately not a markdown library. The point for this sample is that the *height* of the + /// row is a function of the text and of how it wraps, so the virtualizing panel cannot know it + /// without measuring — the same situation any rich-text row puts it in. + /// + public class MarkdownToInlinesConverter : IValueConverter + { + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is not string markdown || string.IsNullOrWhiteSpace(markdown)) + return new InlineCollection(); + + var inlines = new InlineCollection(); + var lines = markdown.Replace("\r\n", "\n").Split('\n'); + var firstBlock = true; + + foreach (var rawLine in lines) + { + var line = rawLine.Trim(); + + if (line.Length == 0) + continue; + + if (!firstBlock) + inlines.Add(new LineBreak()); + firstBlock = false; + + if (TryTakePrefix(line, "### ", out var h3)) + { + AddSpans(inlines, h3, FontWeight.SemiBold, 1.05); + } + else if (TryTakePrefix(line, "## ", out var h2)) + { + AddSpans(inlines, h2, FontWeight.Bold, 1.15); + } + else if (TryTakePrefix(line, "# ", out var h1)) + { + AddSpans(inlines, h1, FontWeight.Bold, 1.3); + } + else if (TryTakePrefix(line, "- ", out var bullet) || TryTakePrefix(line, "* ", out bullet)) + { + inlines.Add(new Run(" • ")); + AddSpans(inlines, bullet, FontWeight.Normal, 1.0); + } + else if (TryTakeOrderedPrefix(line, out var number, out var ordered)) + { + inlines.Add(new Run($" {number}. ")); + AddSpans(inlines, ordered, FontWeight.Normal, 1.0); + } + else + { + AddSpans(inlines, line, FontWeight.Normal, 1.0); + } + } + + return inlines; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + => throw new NotSupportedException(); + + private static bool TryTakePrefix(string line, string prefix, out string rest) + { + if (line.StartsWith(prefix, StringComparison.Ordinal)) + { + rest = line.Substring(prefix.Length); + return true; + } + + rest = line; + return false; + } + + private static bool TryTakeOrderedPrefix(string line, out int number, out string rest) + { + var dot = line.IndexOf(". ", StringComparison.Ordinal); + if (dot > 0 && int.TryParse(line.Substring(0, dot), out number)) + { + rest = line.Substring(dot + 2); + return true; + } + + number = 0; + rest = line; + return false; + } + + /// + /// Splits inline markup into runs. and + /// carry the enclosing block's styling (a heading stays bold + /// and larger even where it contains emphasis). + /// + private static void AddSpans(InlineCollection inlines, string text, FontWeight baseWeight, double sizeFactor) + { + foreach (var (span, kind) in SplitSpans(text)) + { + var run = new Run(span); + + if (sizeFactor != 1.0) + run.FontSize = 14 * sizeFactor; + + run.FontWeight = kind == SpanKind.Bold ? FontWeight.Bold : baseWeight; + + if (kind == SpanKind.Italic) + run.FontStyle = FontStyle.Italic; + + if (kind == SpanKind.Code) + { + run.FontFamily = new FontFamily("Consolas, Menlo, monospace"); + run.Background = new SolidColorBrush(Color.FromArgb(28, 128, 128, 128)); + } + + inlines.Add(run); + } + } + + private enum SpanKind { Plain, Bold, Italic, Code } + + /// + /// Single pass over the line, splitting on the three inline markers. Unclosed markers are + /// treated as literal text rather than swallowing the rest of the line. + /// + private static IEnumerable<(string Text, SpanKind Kind)> SplitSpans(string text) + { + var i = 0; + var plainStart = 0; + + while (i < text.Length) + { + var (marker, kind) = text[i] switch + { + '*' when i + 1 < text.Length && text[i + 1] == '*' => ("**", SpanKind.Bold), + '*' => ("*", SpanKind.Italic), + '`' => ("`", SpanKind.Code), + _ => (null, SpanKind.Plain), + }; + + if (marker is null) + { + i++; + continue; + } + + var contentStart = i + marker.Length; + var close = text.IndexOf(marker, contentStart, StringComparison.Ordinal); + + if (close < 0 || close == contentStart) + { + i += marker.Length; + continue; + } + + if (i > plainStart) + yield return (text.Substring(plainStart, i - plainStart), SpanKind.Plain); + + yield return (text.Substring(contentStart, close - contentStart), kind); + + i = close + marker.Length; + plainStart = i; + } + + if (plainStart < text.Length) + yield return (text.Substring(plainStart), SpanKind.Plain); + } + } +} diff --git a/samples/ControlCatalog/Pages/FieldTemplateSelector.cs b/samples/ControlCatalog/Pages/FieldTemplateSelector.cs new file mode 100644 index 00000000000..974799fa565 --- /dev/null +++ b/samples/ControlCatalog/Pages/FieldTemplateSelector.cs @@ -0,0 +1,106 @@ +using System.Collections.Generic; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using Avalonia.Metadata; + +namespace ControlCatalog.Pages +{ + /// + /// Picks a row template by the row's type, and tells the virtualizing panel how to pool the + /// controls it builds. + /// + /// A flat list of heterogeneous rows cannot use one template, and a plain + /// ItemsControl.DataTemplates collection would recycle nothing: every scroll step would + /// rebuild a row's whole control tree. Implementing + /// instead gives the panel a recycle key per row kind, so a container built for one image row + /// is reused for the next image row and only its bindings change. + /// + /// + /// Templates are supplied as content, so the layouts stay in XAML next to the page: + /// + /// <pages:FieldTemplateSelector x:Key="FieldTemplates"> + /// <DataTemplate DataType="vm:HeadlineItem">…</DataTemplate> + /// <DataTemplate DataType="vm:TextFieldItem">…</DataTemplate> + /// </pages:FieldTemplateSelector> + /// + /// + public class FieldTemplateSelector : IVirtualizingDataTemplate + { + [Content] + public List Templates { get; } = new(); + + /// + /// Whether a recycled container may keep the control tree it already has. + /// + /// Turn it off to see what a plain costs. A template that is not + /// an — or that ignores the control handed back to it, + /// which is what this flag simulates — makes ContentPresenter throw the row's control + /// tree away and build a new one every time a container is reused for a different row. The + /// container pooling still happens; it is the contents that get rebuilt. + /// + public bool RecycleContent { get; set; } = true; + + /// + /// How many row control trees have been built from scratch. With recycling on this settles + /// at roughly the number of containers in play; with it off it climbs for as long as you + /// keep scrolling. + /// + public int Builds { get; private set; } + + public void ResetBuilds() => Builds = 0; + + /// Upper bound on idle containers kept per row kind. + public int MaxPoolSizePerKey { get; set; } = 6; + + /// + /// How many containers warmup pre-builds per row kind. Warmup grows the pool off the row + /// kinds the panel has actually met, so a kind that first appears deep in the list (the + /// markdown and image rows here) is covered when the user reaches it rather than only if it + /// happened to occur near the top. + /// + public int MinPoolSizePerKey { get; set; } = 3; + + /// + /// The recycle key. Row kind is the right granularity: two image rows have the same control + /// tree and differ only in their data, whereas an image row and a number field have nothing + /// in common and must never be swapped for one another. + /// + public object? GetKey(object? data) => data?.GetType(); + + public bool Match(object? data) => FindTemplate(data) is not null; + + public Control? Build(object? data) + { + var built = FindTemplate(data)?.Build(data); + if (built is not null) + Builds++; + return built; + } + + public Control? Build(object? data, Control? existing) + { + // The control tree handed back is already the right one for this row kind (the panel + // only offers a container whose recycle key matches), so keep it and let the + // DataContext change drive the update. Rebuilding here is what a plain IDataTemplate + // effectively does, and it is the expensive path — see RecycleContent. + if (existing is not null && RecycleContent) + return existing; + + return Build(data); + } + + private IDataTemplate? FindTemplate(object? data) + { + if (data is null) + return null; + + foreach (var template in Templates) + { + if (template.Match(data)) + return template; + } + + return null; + } + } +} diff --git a/samples/ControlCatalog/Pages/ListBoxComplexLayoutPage.xaml b/samples/ControlCatalog/Pages/ListBoxComplexLayoutPage.xaml new file mode 100644 index 00000000000..7543b488d1f --- /dev/null +++ b/samples/ControlCatalog/Pages/ListBoxComplexLayoutPage.xaml @@ -0,0 +1,406 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +