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.
@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;
}
}