using System; using System.Collections.Generic; using System.Text.RegularExpressions; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Documents; using Avalonia.Media; namespace Nitrox.Launcher.Models.Controls; /// /// A basic Rich Textbox. Supports bold, italic, underline, colors and hyperlinks. /// /// /// Tag legend:
/// [b][/b] - Bold
/// [i][/i] - Italicize
/// [u][/u] - Underline
/// [#colorHex][/#colorHex] - Change text color
/// [Flavor text](example.com)
///
/// /// [b]Text[/b] => Text
/// [i]Text[/i] => Text
/// [u]Text[/u] => Text
/// [#0000FF]Text[/#0000FF] => Text (with blue foreground)
/// Flavor text
///
public partial class RichTextBlock : TextBlock { private static readonly TextDecorationCollection underlineTextDecoration = [new() { Location = TextDecorationLocation.Underline }]; protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { base.OnPropertyChanged(change); if (change.Property == TextProperty) { Inlines?.Clear(); ParseTextAndAddInlines(Text ?? "", Inlines); // If all text was just tags, set Text to empty. Otherwise, it will be displayed as fallback by Avalonia. if (Inlines?.Count < 1) { Text = ""; } } } [GeneratedRegex(@"\[\/?([^]]+)\](?:\(([^\)]*)\))?")] private static partial Regex TagParserRegex { get; } public static void ParseTextAndAddInlines(ReadOnlySpan text, InlineCollection inlines) { if (inlines == null) { return; } Regex.ValueMatchEnumerator matchEnumerator = TagParserRegex.EnumerateMatches(text); if (!matchEnumerator.MoveNext()) { inlines.Add(new Run(text.ToString())); return; } ValueMatch lastRange = default; Dictionary> activeTags = new(4); do { ValueMatch range = matchEnumerator.Current; // Handle text in-between previous and current tag. ReadOnlySpan textPart = text[(lastRange.Index + lastRange.Length)..range.Index]; if (!textPart.IsEmpty) { inlines.Add(CreateRunWithTags(textPart.ToString(), activeTags)); } // Handle current tag (this tracks state of active tags at current text position) ReadOnlySpan match = text.Slice(range.Index, range.Length); switch (match) { case ['[', '/', ..]: activeTags.Remove(match[2..^1].ToString()); break; case "[b]": activeTags["b"] = static (run, _) => run.FontWeight = FontWeight.Bold; break; case "[u]": activeTags["u"] = static (run, _) => run.TextDecorations = underlineTextDecoration; break; case "[i]": activeTags["i"] = static (run, _) => run.FontStyle = FontStyle.Italic; break; case ['[', ..] when match.IndexOf("](", StringComparison.OrdinalIgnoreCase) > -1: TextBlock textBlock = new(); textBlock.Classes.Add("link"); textBlock.Text = match[1..match.IndexOfAny("]")].ToString(); textBlock.Tag = match[(match.IndexOfAny("(")+1)..match.IndexOfAny(")")].ToString(); inlines.Add(textBlock); break; case ['[', '#', ..]: ReadOnlySpan colorCode = match[1..match.IndexOfAny("]")]; if (!Color.TryParse(colorCode, out Color _)) { goto default; } activeTags[colorCode.ToString()] = static (run, tag) => run.Foreground = new SolidColorBrush(Color.Parse(tag)); break; default: // Unknown tag, let's handle as normal text (issue is likely due to input text not knowing about this RichTextBox format) inlines.Add(CreateRunWithTags(match.ToString(), activeTags)); break; } lastRange = range; } while (matchEnumerator.MoveNext()); // Handle any final text (after the last tag). ReadOnlySpan lastPart = text[(lastRange.Index + lastRange.Length)..]; if (!lastPart.IsEmpty) { inlines.Add(CreateRunWithTags(lastPart.ToString(), activeTags)); } } private static Run CreateRunWithTags(string text, Dictionary> tags) { Run run = new(text); KeyValuePair>? lastColorTag = null; foreach (KeyValuePair> pair in tags) { switch (pair.Key) { case ['#', ..]: // Optimization: only the last color needs to be applied for the current run, ignore all others. lastColorTag = pair; break; default: pair.Value(run, pair.Key); break; } } lastColorTag?.Value(run, lastColorTag.Value.Key); return run; } protected override Type StyleKeyOverride { get; } = typeof(TextBlock); }