ScrollingText.cs
using System;
using Sandbox.UI;
using Sandbox.UI.Construct;

namespace Bimp;

/// <summary>
/// One line of text that scrolls sideways when it doesn't fit, for titles. It waits on the start, scrolls until its end
/// is in view, waits there, and scrolls back - over and over. Text that fits just sits still. Style it like a label
/// (font, colour) and give it a width to fit in - in a row, `flex-grow: 1; flex-basis: 0` (sized from its content, a
/// long title would squash everything beside it).
/// </summary>
[StyleSheet.Inline( "bimp-scrollingtext", Styles )]
public sealed class ScrollingText : Panel
{
	const string Styles = """
		.scrollingtext { flex-direction: row; overflow: hidden; white-space: nowrap; min-width: 0; }
		.scrollingtext > .line { white-space: nowrap; flex-shrink: 0; }
		""";

	/// <summary> Scroll speed, in the panel's own pixels (so it looks the same at any scale). </summary>
	const float PixelsPerSecond = 80;

	/// <summary> How long it rests at each end. </summary>
	const float HoldSeconds = 2.5f;

	readonly Label line;

	float offset;
	int direction = 1; // 1 = towards the end, -1 = back to the start
	RealTimeSince sinceHold;
	bool holding = true;

	public ScrollingText()
	{
		AddClass( "scrollingtext" );
		line = Add.Label( "", "line" );
	}

	public string Text
	{
		get => line.Text;
		set
		{
			value ??= "";
			if ( value == line.Text ) return;
			line.Text = value;
			Restart();
		}
	}

	/// <summary> For probes: widths in our own pixels, whether it's scrolling, and how far. </summary>
	internal string Describe() => $"box {Box.Rect.Width * ScaleFromScreen:0}px, text {line.Box.Rect.Width * ScaleFromScreen:0}px, overflow {ComputedStyle?.OverflowX}, classes [{Classes}], offset {offset:0}, direction {direction}, holding {holding}";

	/// <summary> Back to the start of the text, and wait there. </summary>
	void Restart()
	{
		offset = 0;
		direction = 1;
		Hold();
		line.Style.MarginLeft = 0;
	}

	void Hold()
	{
		holding = true;
		sinceHold = 0;
	}

	public override void Tick()
	{
		base.Tick();

		// Box rects are in screen pixels; offsets are in our own
		var width = Box.Rect.Width * ScaleFromScreen;
		var textWidth = line.Box.Rect.Width * ScaleFromScreen;
		var travel = textWidth - width;
		var overflowing = width > 0 && travel > 1;
		SetClass( "overflowing", overflowing );

		if ( !overflowing )
		{
			if ( offset != 0 || direction != 1 ) Restart();
			return;
		}

		if ( holding )
		{
			if ( sinceHold < HoldSeconds ) return;
			holding = false;
		}

		offset += direction * PixelsPerSecond * RealTime.Delta;

		// the end is in view (or the start again): wait, then go the other way
		if ( offset >= travel || offset <= 0 )
		{
			offset = Math.Clamp( offset, 0, travel );
			direction = -direction;
			Hold();
		}

		line.Style.MarginLeft = Length.Pixels( -offset );
	}
}