Code/TipSegment.cs

Defines TipSegment and TipSegmentKind for tip text parsing. It represents runs of text as Plain, Key (keyboard/mouse keycap) or GamepadButton, and provides Parse to split a line into segments using *...* for keys and `...` for gamepad buttons.

using System.Collections.Generic;

namespace FieldGuide.Tips;

/// <summary>
/// What a run of tip text renders as: plain prose, a keyboard keycap, or a gamepad button chip.
/// </summary>
public enum TipSegmentKind
{
	/// <summary>Ordinary text, rendered as-is.</summary>
	Plain,

	/// <summary>A keyboard key or mouse-button name, rendered as a square keycap. Markup: <c>*W*</c>.</summary>
	Key,

	/// <summary>A gamepad button or stick, rendered as a rounded controller chip. Markup: <c>`A`</c> (backticks).</summary>
	GamepadButton,
}

/// <summary>
/// One run of tip text: either plain prose, a keyboard key that renders as a square keycap, or a
/// gamepad button that renders as a rounded controller chip. <see cref="TipsDisplay"/> walks a tip's
/// segments and styles each kind, so tip authors write a single string with markup around input names
/// instead of embedding UI.
///
/// Markup:
/// <list type="bullet">
/// <item><c>*W*</c> (asterisks) is a keyboard / mouse keycap, e.g. <c>"Hold *B* to block."</c></item>
/// <item><c>`A`</c> (backticks) is a gamepad button, e.g. <c>"Press `A` to jump."</c></item>
/// </list>
/// The two are independent, so one line can carry both for a keyboard-and-gamepad prompt:
/// <c>"Attack with *LMB* / `RT`."</c>. Everything outside the markers is plain.
/// </summary>
public readonly record struct TipSegment( string Text, TipSegmentKind Kind )
{
	/// <summary>True for a keyboard keycap run (<see cref="TipSegmentKind.Key"/>). Kept for readers that
	/// only distinguish keyboard chips from plain text.</summary>
	public bool IsKey => Kind == TipSegmentKind.Key;

	/// <summary>True for a gamepad button run (<see cref="TipSegmentKind.GamepadButton"/>).</summary>
	public bool IsGamepadButton => Kind == TipSegmentKind.GamepadButton;

	/// <summary>
	/// Split a tip line into alternating plain / key / gamepad runs. A matched pair of <c>*</c> marks a
	/// keyboard keycap; a matched pair of <c>`</c> marks a gamepad button; everything else is plain. An
	/// unmatched trailing marker degrades gracefully: its run stays plain.
	/// </summary>
	public static IReadOnlyList<TipSegment> Parse( string text )
	{
		var segments = new List<TipSegment>();
		if ( string.IsNullOrEmpty( text ) )
			return segments;

		var i = 0;
		while ( i < text.Length )
		{
			var c = text[i];

			// A marker only opens a chip if it has a matching partner later in the line; otherwise it is
			// literal text (so a lone `*` or backtick reads plainly instead of eating the rest of the line).
			if ( c == '*' || c == '`' )
			{
				var close = text.IndexOf( c, i + 1 );
				if ( close > i )
				{
					if ( close > i + 1 ) // non-empty run between the markers
					{
						var kind = c == '*' ? TipSegmentKind.Key : TipSegmentKind.GamepadButton;
						segments.Add( new TipSegment( text.Substring( i + 1, close - i - 1 ), kind ) );
					}
					i = close + 1;
					continue;
				}
			}

			// Accumulate a plain run up to the next marker (or end of line).
			var start = i;
			while ( i < text.Length && text[i] != '*' && text[i] != '`' )
				i++;

			// A marker with no partner ahead is literal: fold it into the plain run and keep going.
			if ( i < text.Length && text.IndexOf( text[i], i + 1 ) < 0 )
				i++;

			if ( i > start )
				segments.Add( new TipSegment( text.Substring( start, i - start ), TipSegmentKind.Plain ) );
		}

		return segments;
	}
}