Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions samples/ControlCatalog/Converter/MarkdownToInlinesConverter.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Turns a small subset of markdown into <see cref="Inline"/>s for a TextBlock: ATX headings,
/// bullet and numbered lists, <c>**bold**</c>, <c>*italic*</c> and <c>`code`</c>.
///
/// 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.
/// </summary>
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;
}

/// <summary>
/// Splits inline markup into runs. <paramref name="baseWeight"/> and
/// <paramref name="sizeFactor"/> carry the enclosing block's styling (a heading stays bold
/// and larger even where it contains emphasis).
/// </summary>
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 }

/// <summary>
/// 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.
/// </summary>
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);
}
}
}
106 changes: 106 additions & 0 deletions samples/ControlCatalog/Pages/FieldTemplateSelector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
using System.Collections.Generic;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using Avalonia.Metadata;

namespace ControlCatalog.Pages
{
/// <summary>
/// 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
/// <c>ItemsControl.DataTemplates</c> collection would recycle nothing: every scroll step would
/// rebuild a row's whole control tree. Implementing <see cref="IVirtualizingDataTemplate"/>
/// 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.
/// </summary>
/// <remarks>
/// Templates are supplied as content, so the layouts stay in XAML next to the page:
/// <code>
/// &lt;pages:FieldTemplateSelector x:Key="FieldTemplates"&gt;
/// &lt;DataTemplate DataType="vm:HeadlineItem"&gt;…&lt;/DataTemplate&gt;
/// &lt;DataTemplate DataType="vm:TextFieldItem"&gt;…&lt;/DataTemplate&gt;
/// &lt;/pages:FieldTemplateSelector&gt;
/// </code>
/// </remarks>
public class FieldTemplateSelector : IVirtualizingDataTemplate
{
[Content]
public List<IDataTemplate> Templates { get; } = new();

/// <summary>
/// Whether a recycled container may keep the control tree it already has.
///
/// Turn it off to see what a plain <see cref="IDataTemplate"/> costs. A template that is not
/// an <see cref="IRecyclingDataTemplate"/> — or that ignores the control handed back to it,
/// which is what this flag simulates — makes <c>ContentPresenter</c> 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.
/// </summary>
public bool RecycleContent { get; set; } = true;

/// <summary>
/// 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.
/// </summary>
public int Builds { get; private set; }

public void ResetBuilds() => Builds = 0;

/// <summary>Upper bound on idle containers kept per row kind.</summary>
public int MaxPoolSizePerKey { get; set; } = 6;

/// <summary>
/// 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.
/// </summary>
public int MinPoolSizePerKey { get; set; } = 3;

/// <summary>
/// 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.
/// </summary>
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;
}
}
}
Loading
Loading