Code/SboxSuperuser/UI/ChatBoxRenderer.cs

The in-game chat box, drawn with a vendored ImGui context in place of the built-in box. Keeps the last 60 lines, renders player names through PrefixSystem, fades lines out when closed, highlights mentions, and opens an AdminInputPanel for typing.

using Sandbox;
using SboxSuperuser.Core.Commands;
using Sandbox.Rendering;
using SboxSuperuser.Core.Chat;
using SboxSuperuser.Core.Prefixes;
using SboxSuperuser.Engine.Vendor.ImGui;
using SboxSuperuser.Systems;

namespace SboxSuperuser.UI;

/// <summary>
/// The chat box shown instead of the built-in one while prefixes are on. It behaves like the built-in box (bottom
/// left, lines fade after a while, the chat key opens the input) but draws names with their prefixes, in a neutral
/// dark theme: a #1A1A1A panel with #242424 fields.
/// </summary>
internal sealed partial class ChatBoxRenderer : IDisposable
{
	private const int MaximumLines = 60;
	private const float FadeAfter = 8;
	private const float FadeLength = 1;
	private const int MaximumMessageLength = 256;

	private static readonly Vector4 Base = ChatTheme.Base;
	private static readonly Vector4 Raised = ChatTheme.Raised;
	private static readonly Vector4 Hover = ChatTheme.Hover;
	private static readonly Vector4 Pressed = ChatTheme.Pressed;
	private static readonly Vector4 Line = ChatTheme.Line;
	private static readonly Vector4 Shadow = new( 0, 0, 0, 0.85f );
	private static readonly Vector4 Text = ChatTheme.Text;
	private static readonly Vector4 Muted = ChatTheme.Muted;
	private static readonly Vector4 Notice = Rgb( 0xA8A8A8 );
	// Announcements lead with the tag in bold grey so they read as the server speaking, not a player.
	private static readonly Vector4 TagText = Rgb( 0xB4B4B4 );
	// A line that mentions you gets a soft gold wash and a solid gold edge, like Discord.
	private static readonly Vector4 MentionText = Rgb( 0xF5C451 );
	private static readonly Vector4 MentionWash = new( 0.96f, 0.77f, 0.32f, 0.16f );
	private static readonly Vector4 MentionEdge = Rgb( 0xF5C451 );
	private const float MentionEdgeWidth = 2;

	private readonly Scene _scene;
	private readonly ImGuiContext _context = new();
	private readonly CommandList _commands = new( "Superuser chat" ) { Flags = CommandList.Flag.Hud };
	private readonly List<ChatEntry> _lines = new();
	private readonly List<LineRun> _runs = new();
	private readonly SentHistory _history = new();
	private readonly Action<string> _openPlayer;
	private readonly Func<bool> _canOpenPlayer;
	private readonly Func<string, Completion> _complete;
	private static readonly Vector4 Scrollbar = new( 1, 1, 1, 0.18f );
	private const float ScrollbarWidth = 3;
	private AdminInputPanel _input;
	private MouseVisibility _previousMouse;
	private CameraComponent _camera;
	private string _draft = "";
	private bool _focusInput;
	// How far the history is scrolled up from the newest line, in pixels; only the wheel moves it, while open.
	private float _scrollBack;
	private const int WheelLines = 3;

	/// <summary>openPlayer shows a player in the admin menu; canOpenPlayer says whether that menu is there.</summary>
	/// <param name="complete">Autocomplete for a typed line; command hints sit between the history and the input.</param>
	internal ChatBoxRenderer( Scene scene, Action<string> openPlayer, Func<bool> canOpenPlayer, Func<string, Completion> complete )
	{
		_complete = complete;
		_scene = scene;
		_openPlayer = openPlayer;
		_canOpenPlayer = canOpenPlayer;
		ApplyTheme( _context.Style );
	}

	internal bool IsOpen => _input is not null;

	/// <param name="mentions">The message's @mentions; mentionsMe highlights the whole line for this player.</param>
	internal void AddPlayer( string steamId, string name, string message, IReadOnlyList<Mention> mentions, bool mentionsMe ) =>
		Add( new ChatEntry( steamId, name, message, RealTime.Now, Mentions: mentions, MentionsMe: mentionsMe ) );

	internal void AddSystem( string message ) => Add( new ChatEntry( null, null, message, RealTime.Now ) );

	internal void AddAnnouncement( string tag, string message ) => Add( new ChatEntry( null, null, message, RealTime.Now, tag ) );

	internal void Open()
	{
		if ( IsOpen ) return;
		_previousMouse = Mouse.Visibility;
		_input = new AdminInputPanel( _context.IO ) { Close = Close };
		_history.Reset();
		_focusInput = true;
		_scrollBack = 0;
	}

	internal void Close()
	{
		if ( _input is null ) return;
		Sandbox.UI.InputFocus.Clear( _input );
		_input.Delete( true );
		_input = null;
		for ( var button = 0; button < 3; button++ ) _context.IO.AddMouseButtonEvent( button, false );
		if ( Mouse.Visibility == MouseVisibility.Visible ) Mouse.Visibility = _previousMouse;
		_draft = "";
		_scrollBack = 0;
	}

	/// <summary>While typing, the game gets no movement, look or actions.</summary>
	internal void CaptureInput()
	{
		if ( !IsOpen ) return;
		Mouse.Visibility = MouseVisibility.Visible;
		Input.ClearActions();
		Input.AnalogMove = 0;
		Input.AnalogLook = default;
		if ( !Input.EscapePressed ) return;
		Input.EscapePressed = false;
		Close();
	}

	internal void Draw()
	{
		var previous = ImGuiContext.Current;
		ImGuiContext.Current = _context;
		try
		{
			_context.IO.DeltaTime = MathF.Max( RealTime.Delta, 0.0001f );
			_context.IO.MousePos = IsOpen ? Mouse.Position : new Vector2( -float.MaxValue, -float.MaxValue );
			ImGui.NewFrame();
			DrawBox();
			var lists = ImGui.Render();
			if ( _camera != _scene.Camera )
			{
				if ( _camera.IsValid() ) _camera.RemoveCommandList( _commands );
				_camera = _scene.Camera;
				if ( _camera.IsValid() ) _camera.AddCommandList( _commands, Sandbox.Rendering.Stage.AfterUI, 9000 );
			}
			_commands.Reset();
			using var painter = Painter.Begin( _commands );
			foreach ( var list in lists ) list.Render( painter );
		}
		finally { ImGuiContext.Current = previous; }
	}

	private void DrawBox()
	{
		var scale = _context.AppliedStyleScale;
		var size = new Vector2( 680, 360 ) * scale;
		ImGui.SetNextWindowPos( new Vector2( 24 * scale, Screen.Height - size.y - 180 * scale ) );
		ImGui.SetNextWindowSize( size );
		var flags = ImGuiWindowFlags.NoTitleBar | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove | ImGuiWindowFlags.NoSavedSettings
			| ImGuiWindowFlags.NoFocusOnAppearing | ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoScrollbar;
		if ( !IsOpen ) flags |= ImGuiWindowFlags.NoInputs | ImGuiWindowFlags.NoBackground;
		ImGui.PushStyleVar( ImGuiStyleVar.WindowBorderSize, IsOpen ? 1 : 0 );
		if ( ImGui.Begin( "##superuser-chat", flags ) )
		{
			DrawHeader();
			// The input keeps its room open or closed, so sending a message (which closes the chat) doesn't move the
			// history; autocomplete floats above it instead of taking room.
			var inputHeight = ImGui.GetFrameHeightWithSpacing() + 4 * scale;
			// ImGui's own scroll is never used: it lags a frame behind, which made the history jump on open and close.
			ImGui.BeginChild( "lines", new Vector2( 0, -inputHeight ), ImGuiChildFlags.None,
				ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse );
			DrawLines( scale );
			ImGui.EndChild();
			if ( IsOpen ) DrawInput();
		}
		ImGui.End();
		ImGui.PopStyleVar();
	}

	// Laid out invisibly while closed, so the history has the same room open or closed.
	private void DrawHeader()
	{
		ImGui.PushStyleVar( ImGuiStyleVar.Alpha, IsOpen ? 1 : 0 );
		ImGui.TextColored( Text, "{0}", "Chat" );
		ImGui.Separator();
		ImGui.Spacing();
		ImGui.PopStyleVar();
	}

	private void DrawLines( float scale )
	{
		var now = RealTime.Now;
		var system = _scene.GetSystem<PrefixSystem>();
		var clock = PrefixSystem.Now;
		var gap = 2 * scale;
		var names = IsOpen && _canOpenPlayer();
		// Newest lines sit right above the input. The lines shown this frame are summed first, so opening or closing
		// (which shows or hides faded lines) never lays out with the previous frame's height.
		MeasureNewLines( system, clock, names, gap );
		var total = 0f;
		foreach ( var line in _lines )
			if ( Alpha( line, now ) > 0 ) total += line.Height;
		if ( total <= 0 ) return;
		var room = ImGui.GetContentRegionAvail().y;
		if ( IsOpen && ImGui.IsWindowHovered() ) _scrollBack += ImGui.GetIO().MouseWheel * WheelLines * ImGui.GetTextLineHeightWithSpacing();
		_scrollBack = Math.Clamp( _scrollBack, 0, MathF.Max( 0, total - room ) );
		if ( IsOpen && total > room ) DrawScrollbar( room, total );
		ImGui.SetCursorPosY( room - total + _scrollBack );
		foreach ( var line in _lines )
		{
			var alpha = Alpha( line, now );
			if ( alpha <= 0 ) continue;
			var top = ImGui.GetCursorPosY();
			ImGui.PushStyleVar( ImGuiStyleVar.Alpha, alpha );
			DrawLine( line, system, clock, names );
			ImGui.PopStyleVar();
			ImGui.Dummy( new Vector2( 0, gap ) );
			line.Height = ImGui.GetCursorPosY() - top;
		}
	}

	/// <summary>
	/// Lays out lines that have never been drawn, invisibly and below the visible area, so a new message's real
	/// height (wrapped, with a mention) is known before the history is positioned and nothing jumps a frame later.
	/// </summary>
	private void MeasureNewLines( PrefixSystem system, double clock, bool names, float gap )
	{
		var hidden = ImGui.GetContentRegionAvail().y + 10000;
		foreach ( var line in _lines )
		{
			if ( line.Height > 0 ) continue;
			ImGui.SetCursorPosY( hidden );
			ImGui.PushStyleVar( ImGuiStyleVar.Alpha, 0 );
			DrawLine( line, system, clock, false );
			ImGui.PopStyleVar();
			ImGui.Dummy( new Vector2( 0, gap ) );
			line.Height = ImGui.GetCursorPosY() - hidden;
		}
		ImGui.SetCursorPosY( 0 );
	}

	/// <summary>A thin bar on the right edge while there is more history than fits; the wheel scrolls it.</summary>
	private void DrawScrollbar( float room, float total )
	{
		var origin = ImGui.GetCursorScreenPos();
		var right = origin.x + ImGui.GetContentRegionAvail().x;
		var thumb = MathF.Max( 12, room * room / total );
		// _scrollBack counts up from the newest line, so the thumb sits at the bottom until you scroll back.
		var travel = room - thumb;
		var top = origin.y + travel * (1 - _scrollBack / (total - room));
		ImGui.GetWindowDrawList().AddRectFilled( new Vector2( right - ScrollbarWidth, top ), new Vector2( right, top + thumb ),
			ImGui.GetColorU32( Scrollbar ), ScrollbarWidth );
	}

	private float Alpha( ChatEntry line, float now ) => IsOpen ? 1f : Math.Clamp( 1 - (now - line.Added - FadeAfter) / FadeLength, 0, 1 );

	// Closed, lines sit straight on the game with a thin dark shadow so they stay readable; open, the panel is behind them.
	private void DrawLine( ChatEntry line, PrefixSystem system, double clock, bool names )
	{
		var shadow = !IsOpen;
		var left = ImGui.GetCursorScreenPos().x;
		var right = left + ImGui.GetContentRegionAvail().x;
		if ( line.MentionsMe ) DrawMentionWash( line, left, right );
		ImGui.PushTextWrapPos( ImGui.GetContentRegionMax().x );
		if ( line.Tag is not null )
		{
			DrawTag( line.Tag, shadow );
			ImGui.SameLine();
			FlowText( line, Text, left, right, shadow );
		}
		else if ( line.SteamId is null )
		{
			FlowText( line, Notice, left, right, shadow );
		}
		else
		{
			DrawAvatar( line.SteamId );
			ImGui.SameLine();
			_runs.Clear();
			system.Render( line.SteamId, line.Name, clock, _runs );
			StyledText.Draw( _runs, shadow );
			if ( names ) NameLink( line.SteamId );
			ImGui.SameLine( 0, 0 );
			ShadowedText( ":", Muted, right, shadow );
			ImGui.SameLine();
			FlowText( line, Text, left, right, shadow );
		}
		ImGui.PopTextWrapPos();
	}

	// A name is a link while the chat is open: hover underlines it, a click opens that player in the admin menu.
	private void NameLink( string steamId )
	{
		var min = ImGui.GetItemRectMin();
		var max = ImGui.GetItemRectMax();
		if ( !ImGui.IsMouseHoveringRect( min, max ) ) return;
		ImGui.GetWindowDrawList().AddLine( new Vector2( min.x, max.y ), max, ImGui.GetColorU32( Muted ) );
		ImGui.SetMouseCursor( ImGuiMouseCursor.Hand );
		ImGui.SetTooltip( "{0}", "Open in the admin menu" );
		if ( ImGui.IsMouseClicked( ImGuiMouseButton.Left ) ) _openPlayer( steamId );
	}

	private void Add( ChatEntry line )
	{
		_lines.Add( line );
		if ( _lines.Count > MaximumLines ) _lines.RemoveAt( 0 );
		_scrollBack = 0;
	}

	// Neutral greys only: the stock ImGui theme's blue never shows in the chat box.
	private static void ApplyTheme( ImGuiStyle style )
	{
		style.WindowRounding = 4;
		style.ChildRounding = 3;
		style.FrameRounding = 3;
		style.ScrollbarRounding = 3;
		style.WindowPadding = new Vector2( 12, 10 );
		style.FramePadding = new Vector2( 10, 6 );
		style.ItemSpacing = new Vector2( 6, 3 );
		style.ScrollbarSize = 8;
		style.WindowBorderSize = 1;

		style[ ImGuiCol.WindowBg ] = Base;
		style[ ImGuiCol.ChildBg ] = Vector4.Zero;
		style[ ImGuiCol.Border ] = Line;
		style[ ImGuiCol.Separator ] = Line;
		style[ ImGuiCol.Text ] = Text;
		style[ ImGuiCol.TextDisabled ] = Muted;
		style[ ImGuiCol.FrameBg ] = Raised;
		style[ ImGuiCol.FrameBgHovered ] = Hover;
		style[ ImGuiCol.FrameBgActive ] = Pressed;
		style[ ImGuiCol.TextSelectedBg ] = Rgb( 0x4A4A4A );
		style[ ImGuiCol.InputTextCursor ] = Text;
		style[ ImGuiCol.ScrollbarBg ] = Vector4.Zero;
		style[ ImGuiCol.ScrollbarGrab ] = Rgb( 0x3D3D3D );
		style[ ImGuiCol.ScrollbarGrabHovered ] = Rgb( 0x4A4A4A );
		style[ ImGuiCol.ScrollbarGrabActive ] = Rgb( 0x575757 );
		style[ ImGuiCol.NavCursor ] = Vector4.Zero;
	}

	private static Vector4 Rgb( int hex ) => ChatTheme.Rgb( hex );

	public void Dispose()
	{
		Close();
		_commands.Reset();
		if ( _camera.IsValid() ) _camera.RemoveCommandList( _commands );
		if ( ReferenceEquals( ImGuiContext.Current, _context ) ) ImGuiContext.Current = null;
	}

	private sealed record ChatEntry( string SteamId, string Name, string Message, float Added, string Tag = null,
		IReadOnlyList<Mention> Mentions = null, bool MentionsMe = false )
	{
		internal IReadOnlyList<Mention> Mentions { get; init; } = Mentions ?? Array.Empty<Mention>();

		internal WrappedText Wrap { get; set; }

		// Measured when last drawn; 0 until then.
		internal float Height { get; set; }
	}
}