RichText.razor

A Razor UI component that parses a lightweight rich-text syntax and creates child Panels and Labels accordingly. It discovers RichTextPanelAttribute types, matches regex patterns against the Text, splits plain text into words/lines, and instantiates appropriate panels (or Label) and sets image size for IRichTextPanel implementations.

ReflectionObfuscated Code
@using Sandbox
@using Sandbox.UI
@using System.Collections.Generic
@using System.Text.RegularExpressions
@using System

<root>

</root>

@code
{
	public string Text { get; set; } = "";
	public float? ImageSize { get; set; } = null;

	protected override void OnParametersSet ()
	{
		base.OnParametersSet();
		DeleteChildren( true );

		var components = ParseText( Text );

		foreach ( var (panelType, innerText) in components )
		{
			if ( panelType == typeof( Label ) )
			{
				// First split by newlines to handle line breaks
				// Handle both \n and \r\n line endings
				var lines = innerText.Split( new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None );

				for ( int lineIndex = 0; lineIndex < lines.Length; lineIndex++ )
				{
					// Add line break element before each line after the first
					if ( lineIndex > 0 )
					{
						var lineBreak = new Panel();
						lineBreak.Style.FlexBasis = Length.Percent( 100 );
						lineBreak.Style.Width = Length.Percent( 100 );
						lineBreak.Style.Height = 0;
						lineBreak.Style.FlexShrink = 0;
						lineBreak.Style.FlexGrow = 0;
						lineBreak.Style.MinWidth = Length.Percent( 100 );
						AddChild( lineBreak );
					}

					// Now split the line by spaces and process words
					var split = lines[lineIndex].Split( " " );
					foreach ( var s in split )
					{
						if(string.IsNullOrWhiteSpace(s))
						{
							continue;
						}
						var label = new Label( s );
						// Add class for punctuation to control spacing
						if ( s.Length == 1 && char.IsPunctuation( s[0] ) )
						{
							label.AddClass( "punctuation" );
						}
						// Add class for "s" or "s)" to control spacing (for times like "5s" or "5s)")
						else if ( s == "s" || s == "s)" )
						{
							label.AddClass( "suffix-s" );
						}
						AddChild( label );
					}
				}
			}
			else
			{
				var panel = TypeLibrary.Create<Panel>( panelType );
				AddChild( panel );

				if ( panel is IRichTextPanel richTextPanel )
				{
					richTextPanel.ImageSize = ImageSize;
					richTextPanel.ParseRichText( innerText );
				}
				else
				{
					panel.AddChild( new Label( innerText ) );
				}
			}
		}
	}

	/// <summary>
	/// Parses rich text and returns a list of components to create
	/// </summary>
	/// <param name="text">The text to parse</param>
	/// <returns>List of (ComponentType, InnerText) pairs where ComponentType is either a Panel type or typeof(Label)</returns>
	public static List<(System.Type Type, string Text)> ParseText ( string text )
	{
		var result = new List<(System.Type Type, string Text)>();

		if ( string.IsNullOrEmpty( text ) )
			return result;

		var allPanels = TypeLibrary.GetTypesWithAttribute<RichTextPanelAttribute>();

		// Create a list of all matches with their positions
		var matches = new List<(Match Match, System.Type PanelType, int Priority, bool useCapture, string Pattern)>();

		foreach ( var panelType in allPanels )
		{
			var pattern = panelType.Attribute.Pattern;
			if ( string.IsNullOrEmpty( pattern ) )
				continue;

			var regex = new Regex( pattern );
			var regexMatches = regex.Matches( text );

			// Priority based on specificity - longer patterns get higher priority
			// Also give higher priority to patterns with more complex regex features
			var priority = pattern.Length;
			if ( pattern.Contains( "(?<!" ) || pattern.Contains( "(?!" ) )
				priority += 100; // Boost priority for lookahead/lookbehind patterns

			foreach ( Match match in regexMatches )
			{
				matches.Add( (match, panelType.Type.TargetType, priority, panelType.Attribute.UseCaptureGroup, pattern) );
			}
		}

		// Sort matches by length (longer matches first for proper nesting),
		// then by priority (higher priority first), then by position
		matches.Sort( ( a, b ) =>
		{
			// Pre-priority: If we have a manually input ExtraPriority, use that first
			// if ( a.Priority == b.Priority && a.ExtraPriority != b.ExtraPriority )
			// 	return b.ExtraPriority.CompareTo( -a.ExtraPriority );
			
			// First priority: longer matches first (this handles nesting properly)
			var lengthComparison = b.Match.Length.CompareTo( a.Match.Length );
			if ( lengthComparison != 0 )
				return lengthComparison;

			// Second priority: higher pattern priority first
			var priorityComparison = b.Priority.CompareTo( a.Priority );
			if ( priorityComparison != 0 )
				return priorityComparison;

			// Third priority: earlier position first
			return a.Match.Index.CompareTo( b.Match.Index );
		} );

		// Remove overlapping matches (keep higher priority ones)
		var finalMatches = new List<(Match Match, System.Type PanelType, bool useCapture)>();
		foreach ( var (match, panelType, priority, useCapture, pattern) in matches )
		{
			var overlaps = false;
			foreach ( var (existingMatch, _, _) in finalMatches )
			{
				if ( match.Index < existingMatch.Index + existingMatch.Length &&
					match.Index + match.Length > existingMatch.Index )
				{
					overlaps = true;
					break;
				}
			}

			if ( !overlaps )
			{
				finalMatches.Add( (match, panelType, useCapture) );
			}
		}

		// Sort final matches by position
		finalMatches.Sort( ( a, b ) => a.Match.Index.CompareTo( b.Match.Index ) );

		// Build the component list by processing text segments and matches
		var lastIndex = 0;
		foreach ( var (match, panelType, useCapture) in finalMatches )
		{
			// Add any text before this match as Label components
			if ( match.Index > lastIndex )
			{
				var beforeText = text.Substring( lastIndex, match.Index - lastIndex );
				if ( !string.IsNullOrEmpty( beforeText ) )
				{
					result.Add( (typeof( Label ), beforeText) );
				}
			}

			// Add the panel component
			// Use full match by default. Only use first capture group if it's clearly extracting content from wrapper syntax
			var innerText = match.Value;
			if ( useCapture && match.Groups.Count > 1 && !string.IsNullOrEmpty( match.Groups[1].Value ) )
			{
				var captureGroup = match.Groups[1].Value;
				var fullMatch = match.Value;

				// Only use capture group if the full match starts and ends with wrapper characters
				// that are NOT part of the capture group (indicating wrapper syntax like **text** or <i>text</i>)
				var beforeCapture = fullMatch.Substring( 0, match.Groups[1].Index - match.Index );
				var afterCapture = fullMatch.Substring( match.Groups[1].Index - match.Index + match.Groups[1].Length );

				// Use capture group only if there are clear wrapper characters before AND after
				if ( !string.IsNullOrEmpty( beforeCapture ) && !string.IsNullOrEmpty( afterCapture ) )
				{
					innerText = captureGroup;
				}
			}
			result.Add( (panelType, innerText) );

			lastIndex = match.Index + match.Length;
		}

		// Add any remaining text as a Label component
		if ( lastIndex < text.Length )
		{
			var remainingText = text.Substring( lastIndex );
			if ( !string.IsNullOrEmpty( remainingText ) )
			{
				result.Add( (typeof( Label ), remainingText) );
			}
		}

		return result;
	}
}