Editor/HotCodeEditor/CodeView/TextLayout.cs
using System.Collections.Generic;

/// <summary>
/// Column maths for monospaced text: tab expansion and word wrapping.
/// A line is shown as one or more rows; each row starts at a character index.
/// Columns inside a row count from the start of that row.
/// </summary>
public static class TextLayout
{
	/// <summary>
	/// Columns per tab. Set from HotCode's settings.
	/// </summary>
	public static int TabSize { get; set; } = 4;

	private static readonly int[] SingleRow = { 0 };

	private static int Advance( char c, int col ) => c == '\t' ? (col / TabSize + 1) * TabSize : col + 1;

	/// <summary>
	/// Column that character <paramref name="index"/> lands on, counting from <paramref name="rowStart"/>.
	/// </summary>
	public static int ColumnOf( string line, int rowStart, int index )
	{
		int col = 0;
		for ( int i = rowStart; i < index && i < line.Length; i++ )
			col = Advance( line[i], col );
		return col;
	}

	public static int Width( string line ) => ColumnOf( line, 0, line.Length );

	/// <summary>
	/// Character index in [rowStart, rowEnd] nearest to a (possibly fractional) column.
	/// </summary>
	public static int IndexAtColumn( string line, int rowStart, int rowEnd, float column )
	{
		int col = 0;
		for ( int i = rowStart; i < rowEnd && i < line.Length; i++ )
		{
			var next = Advance( line[i], col );
			if ( column < (col + next) / 2f ) return i;
			col = next;
		}

		return rowEnd;
	}

	/// <summary>
	/// Where each row of a line starts when wrapped to <paramref name="columns"/>. Breaks after
	/// whitespace when it can, mid-word when a word is longer than a row. Columns &lt;= 0 means no wrapping.
	/// </summary>
	public static int[] Wrap( string line, int columns )
	{
		if ( columns <= 0 || line.Length == 0 || Width( line ) <= columns )
			return SingleRow;

		var starts = new List<int> { 0 };
		int rowStart = 0;
		int col = 0;
		int lastBreak = -1;
		var rowHasText = false;

		for ( int i = 0; i < line.Length; i++ )
		{
			var next = Advance( line[i], col );

			// Loops at most twice: breaking at the last space can still leave this character
			// (say, a tab) too wide, and then it moves to a row of its own start
			while ( next > columns && i > rowStart )
			{
				// Whitespace stays at the end of the row it follows, so a row never starts with the break
				var breakAt = lastBreak > rowStart && lastBreak <= i ? lastBreak : i;
				starts.Add( breakAt );
				rowStart = breakAt;
				lastBreak = -1;
				rowHasText = false;

				col = ColumnOf( line, rowStart, i );
				next = Advance( line[i], col );
			}

			col = next;

			var isSpace = line[i] == ' ' || line[i] == '\t';
			if ( !isSpace ) rowHasText = true;

			// Leading indentation isn't a break point, or we'd get rows of nothing but indent
			else if ( rowHasText ) lastBreak = i + 1;
		}

		return starts.ToArray();
	}
}