Code/SboxSuperuser/UI/ChatBoxRenderer.Text.cs

Text-drawing half of the chat box ImGui renderer. Word-wraps each chat entry, caches the wrap and its plain/mention segments per entry and width, draws shadowed text, @mention highlighting, the mention wash, the speaker's Steam avatar and the announcement tag.

using Sandbox;
using SboxSuperuser.Core.Chat;
using SboxSuperuser.Core.Commands;
using SboxSuperuser.Engine.Vendor.ImGui;

namespace SboxSuperuser.UI;

/// <summary>How a chat line is drawn: wrapped text with mentions, the avatar, the announcement tag and shadows.</summary>
internal sealed partial class ChatBoxRenderer
{
	// The shadow is the same text, wrapped the same way, one pixel down and right.
	/// <summary>
	/// Draws the message from the cursor, wrapping later lines back to the left edge instead of under the name or
	/// tag, with @mentions in gold. The wrap and its coloured pieces are worked out once per width and kept on the
	/// entry, so drawing each frame doesn't allocate.
	/// </summary>
	private static void FlowText( ChatEntry line, Vector4 color, float left, float right, bool shadow )
	{
		var first = right - ImGui.GetCursorScreenPos().x;
		var rest = right - left;
		if ( line.Wrap is not { } wrap || wrap.First != first || wrap.Rest != rest )
			line.Wrap = wrap = new WrappedText( first, rest, Segment( line, WrapWords( line.Message, first, rest ) ) );
		foreach ( var row in wrap.Rows )
		{
			for ( var index = 0; index < row.Length; index++ )
			{
				if ( index > 0 ) ImGui.SameLine( 0, 0 );
				ShadowedText( row[ index ].Text, row[ index ].Mention ? MentionText : color, right, shadow );
			}
		}
	}

	/// <summary>Greedy word wrap as (start, end) ranges of the message: the first row fits in first pixels, the rest in rest.</summary>
	private static List<(int Start, int End)> WrapWords( string text, float first, float rest )
	{
		var rows = new List<(int Start, int End)>();
		float Limit() => rows.Count == 0 ? first : rest;
		bool Fits( int start, int end ) => ImGui.CalcTextSize( text[ start..end ] ).x <= Limit();
		int rowStart = 0, rowEnd = 0, position = 0;
		foreach ( var word in text.Split( ' ' ) )
		{
			var wordStart = position;
			var wordEnd = position + word.Length;
			position = wordEnd + 1;
			if ( Fits( rowStart, wordEnd ) ) { rowEnd = wordEnd; continue; }
			if ( rowEnd > rowStart ) rows.Add( (rowStart, rowEnd) );
			rowStart = wordStart;
			rowEnd = wordEnd;
			// A word longer than a whole row breaks wherever it has to.
			while ( rowEnd - rowStart > 1 && !Fits( rowStart, rowEnd ) )
			{
				var fit = rowEnd - 1;
				while ( fit > rowStart + 1 && !Fits( rowStart, fit ) ) fit--;
				rows.Add( (rowStart, fit) );
				rowStart = fit;
			}
		}
		rows.Add( (rowStart, rowEnd) );
		return rows;
	}

	/// <summary>Cuts each row into plain and mention pieces.</summary>
	private static Piece[][] Segment( ChatEntry line, List<(int Start, int End)> rows )
	{
		var result = new Piece[ rows.Count ][];
		for ( var index = 0; index < rows.Count; index++ )
		{
			var (start, end) = rows[ index ];
			var pieces = new List<Piece>();
			var cursor = start;
			foreach ( var mention in line.Mentions )
			{
				var mentionStart = Math.Max( mention.Start, start );
				var mentionEnd = Math.Min( mention.Start + mention.Length, end );
				if ( mentionStart >= mentionEnd ) continue;
				if ( mentionStart > cursor ) pieces.Add( new Piece( line.Message[ cursor..mentionStart ], false ) );
				pieces.Add( new Piece( line.Message[ mentionStart..mentionEnd ], true ) );
				cursor = mentionEnd;
			}
			if ( end > cursor || pieces.Count == 0 ) pieces.Add( new Piece( line.Message[ cursor..end ], false ) );
			result[ index ] = pieces.ToArray();
		}
		return result;
	}

	private readonly record struct Piece( string Text, bool Mention );

	private sealed record WrappedText( float First, float Rest, Piece[][] Rows );

	// Avatars load once per player; the engine fills them in when Steam answers.
	private readonly Dictionary<string, Texture> _avatars = new();
	private const float AvatarRounding = 3;

	/// <summary>The speaker's Steam avatar, one text line tall, in front of their prefix.</summary>
	private void DrawAvatar( string steamId )
	{
		if ( !_avatars.TryGetValue( steamId, out var avatar ) ) _avatars[ steamId ] = avatar = Texture.Load( $"avatarsmall:{steamId}", false );
		var size = ImGui.GetTextLineHeight();
		var position = ImGui.GetCursorScreenPos();
		if ( avatar is not null )
			ImGui.GetWindowDrawList().AddImageRounded( avatar, position, position + new Vector2( size, size ), Vector2.Zero, Vector2.One,
				ImGui.GetColorU32( Vector4.One ), AvatarRounding );
		ImGui.Dummy( new Vector2( size, size ) );
	}

	/// <summary>The gold wash behind a line that mentions this player, sized from the rows it wrapped to last frame.</summary>
	private static void DrawMentionWash( ChatEntry line, float left, float right )
	{
		var top = ImGui.GetCursorScreenPos();
		var height = (line.Wrap?.Rows.Length ?? 1) * ImGui.GetTextLineHeightWithSpacing();
		var draw = ImGui.GetWindowDrawList();
		draw.AddRectFilled( new Vector2( left - 4, top.y - 1 ), new Vector2( right, top.y + height - 1 ), ImGui.GetColorU32( MentionWash ) );
		draw.AddRectFilled( new Vector2( left - 4, top.y - 1 ), new Vector2( left - 4 + MentionEdgeWidth, top.y + height - 1 ), ImGui.GetColorU32( MentionEdge ) );
	}

	/// <summary>The announcement tag in grey, thickened to look bold.</summary>
	private static void DrawTag( string tag, bool shadow )
	{
		var position = ImGui.GetCursorScreenPos();
		var draw = ImGui.GetWindowDrawList();
		if ( shadow ) draw.AddText( ImGui.GetFontSize(), position + Vector2.One, ImGui.GetColorU32( Shadow ), tag );
		// Drawing the text twice, a pixel apart, thickens it where the font has no bold weight.
		draw.AddText( ImGui.GetFontSize(), position + new Vector2( 1, 0 ), ImGui.GetColorU32( TagText ), tag );
		ImGui.TextColored( TagText, "{0}", tag );
	}

	private static void ShadowedText( string text, Vector4 color, float right, bool shadow )
	{
		if ( shadow )
		{
			var position = ImGui.GetCursorScreenPos();
			ImGui.GetWindowDrawList().AddText( ImGui.GetFontSize(), position + Vector2.One, ImGui.GetColorU32( Shadow ), text,
				MathF.Max( 1, right - position.x ) );
		}
		ImGui.TextColored( color, "{0}", text );
	}
}