UI/SkafinityBoard.cs

UI presentation rules for the Skafinity board. Contains pure functions and constants that decide labels, grid layout, time/percent formatting, and user-visible copy for the UI without touching engine APIs.

using System;
using System.Collections.Generic;

namespace Skafinity;

/// <summary>
/// The board's PRESENTATION RULES, with no widget toolkit in sight: what the controls are called,
/// what they say when they change, how a length or a playlist row is worded, and how the vibe's
/// fields fall into a grid. Everything here is a pure function of engine state.
/// </summary>
/// <remarks>
/// <para>This exists because the same board is drawn twice — once as a Razor panel here, once as
/// <c>web/skafinity-element.js</c> — and the half that drifts between two drawings of one design is
/// never the layout, it is the wording and the small derived decisions: which button is disabled,
/// what a row says when nothing is cached, whether a knob repeats its column's name. Those are
/// written down once, here.</para>
///
/// <para><b>Framework-free on purpose, and not yet shared.</b> Nothing in this file touches
/// <c>Sandbox.*</c>, so it can move under <c>Code/Engine/</c> — the folder both targets compile —
/// the day the web asks the wasm for it instead of keeping its own copy. It is deliberately NOT
/// there yet: a file under <c>Engine/</c> is a file in the wasm bundle, and adding one costs a full
/// AOT re-stage (see the stale-bundle gate in CLAUDE.md) for code nothing on that side calls yet.
/// Keep it free of engine-target-hostile types so that move stays a move rather than a rewrite.</para>
/// </remarks>
public static class SkafinityBoard
{
	// ── The grid ────────────────────────────────────────────────────────────────────────────────
	/// <summary>One header per vibe-matrix column. Column 0 (VOLUME) is a local mix preference and
	/// never travels; columns 1..4 are the wire. The grid is rectangular, so a voice with nothing in
	/// its last column simply leaves that cell empty.</summary>
	public static readonly string[] ColumnHeaders = { "VOLUME", "TONE", "CHARACTER", "EXTRA", "MORE" };

	/// <summary>One row of the per-instrument mixer: a voice and its cells, one per
	/// <see cref="ColumnHeaders"/> entry, null where this genre leaves a column empty.</summary>
	public readonly struct MatrixRow
	{
		/// <summary>Voice name — the row label (BASS, DRUMS, …).</summary>
		public string Voice { get; init; }
		/// <summary>Cells by column index; null = this genre has no knob there.</summary>
		public VibeCodec.Field[] Cells { get; init; }
	}

	/// <summary>Lay the genre's vibe fields out as the mixer grid: one row per voice, in the
	/// library's own display order. Fields with no voice are GLOBAL and come back from
	/// <see cref="Globals"/> instead.</summary>
	/// <remarks>Driven entirely from the field metadata, so a new genre — or a new knob — is a pure
	/// engine change and there is no field table in any UI.</remarks>
	public static List<MatrixRow> Matrix( int genre )
	{
		var order = new List<string>();
		var byVoice = new Dictionary<string, VibeCodec.Field[]>();
		foreach ( var f in VibeCodec.Fields( genre ) )
		{
			if ( f.Voice == null ) continue;
			if ( !byVoice.TryGetValue( f.Voice, out var cells ) )
			{
				cells = new VibeCodec.Field[ColumnHeaders.Length];
				byVoice[f.Voice] = cells;
				order.Add( f.Voice );
			}
			if ( f.Column >= 0 && f.Column < cells.Length ) cells[f.Column] = f;
		}
		var rows = new List<MatrixRow>( order.Count );
		foreach ( var v in order ) rows.Add( new MatrixRow { Voice = v, Cells = byVoice[v] } );
		return rows;
	}

	/// <summary>The genre's knobs that belong to no instrument — the GLOBAL strip under the grid.
	/// Often empty (the globals have been retired to reserved wire slots), and a heading over an
	/// empty grid reads as a panel that failed to draw something, so callers check.</summary>
	public static List<VibeCodec.Field> Globals( int genre )
	{
		var list = new List<VibeCodec.Field>();
		foreach ( var f in VibeCodec.Fields( genre ) )
			if ( f.Voice == null ) list.Add( f );
		return list;
	}

	/// <summary>What to write above a knob in the grid: nothing when the column header already says
	/// it (VOLUME under VOLUME reads as a mistake), the field's own name otherwise.</summary>
	public static string KnobLabel( VibeCodec.Field f, int column ) =>
		f == null ? "" : f.Name == ColumnHeaders[column] ? "" : f.Name;

	/// <summary>Index of a field within its genre's field list — what
	/// <see cref="SkafinityPlayer.SetVibe"/> takes.</summary>
	public static int FieldIndex( int genre, VibeCodec.Field field )
	{
		var fields = VibeCodec.Fields( genre );
		for ( int i = 0; i < fields.Count; i++ )
			if ( ReferenceEquals( fields[i], field ) ) return i;
		return -1;
	}

	/// <summary>Which of a choice field's options a 0..1 value selects.</summary>
	public static int ChoiceIndex( VibeCodec.Field f, float norm ) =>
		f?.Choices == null ? 0
		: Math.Clamp( (int)MathF.Round( norm * (f.Choices.Length - 1) ), 0, f.Choices.Length - 1 );

	/// <summary>…and the 0..1 value that selects option <paramref name="k"/>.</summary>
	public static float ChoiceNorm( VibeCodec.Field f, int k ) =>
		f?.Choices == null || f.Choices.Length < 2 ? 0f : k / (float)(f.Choices.Length - 1);

	// ── Numbers as words ────────────────────────────────────────────────────────────────────────
	/// <summary>Shown where a length would be if there were one. A song that has not been rendered
	/// has no length to state, and an honest dash beats 0:00 — which reads as a song of no length.</summary>
	public const string NoTime = "–:––";

	/// <summary>m:ss, or <see cref="NoTime"/> when the length is not known yet.</summary>
	public static string Time( double seconds, bool known = true )
	{
		if ( !known || double.IsNaN( seconds ) ) return NoTime;
		int t = (int)Math.Round( Math.Max( 0, seconds ) );
		return $"{t / 60}:{(t % 60):00}";
	}

	/// <summary>A 0..1 fraction as a CSS width.</summary>
	public static string Percent( float f ) => $"{(int)MathF.Round( Math.Clamp( f, 0f, 1f ) * 100 )}%";

	/// <summary>Genre name for an id, or "?" — a UI drawing a row for a genre the engine does not
	/// have should show that rather than throw.</summary>
	public static string GenreName( int g ) =>
		g >= 0 && g < VibeCodec.GenreCount ? VibeCodec.Genres[g] : "?";

	/// <summary>The caret column of a playlist row: on the song you are hearing, nothing otherwise.
	/// A column rather than a prefix so every row's number starts at the same x.</summary>
	public static string RowCaret( SkafinityPlayer.QueueEntry e ) => e.Current ? "▶" : "";

	/// <summary>What a playlist row says on its right-hand side. Generating rows draw a bar instead
	/// and never reach this.</summary>
	public static string RowStatus( SkafinityPlayer.QueueEntry e ) =>
		e.Current ? Copy.RowNow : e.Cached ? Copy.RowReady : e.Past ? Copy.RowGone : Copy.RowPending;

	// ── The words ───────────────────────────────────────────────────────────────────────────────
	/// <summary>Every user-visible string on the board, in one place. The tooltips carry the reason a
	/// control exists, which is the part that is genuinely hard to reconstruct — "reroll" and
	/// "randomize" are both dice, and only their tooltips say why there are two.</summary>
	public static class Copy
	{
		// Transport
		public const string Prev = "⏮";
		public const string PrevTitle = "Previous song";
		public const string Play = "▶";
		public const string Pause = "⏸";
		public const string PlayTitle = "Play / Pause";
		public const string Next = "⏭";
		public const string NextTitle = "Next song";
		/// <summary>Ends in the hash on purpose — see <see cref="Hash"/>.</summary>
		public const string NowPlaying = "now playing #";
		/// <summary>A number sign, ALONE, as its own label.</summary>
		/// <remarks>A label whose text is LONGER than one character and begins with <c>#</c> is a
		/// localisation token: the engine looks the rest up as a phrase and renders what comes back,
		/// so <c>#24</c> silently becomes <c>24</c>. That is why no label here is built as "#" plus a
		/// number — the hash either ends the text before it, or stands alone in a panel of its own,
		/// where the length rule leaves it untouched.</remarks>
		public const string Hash = "#";
		public const string Volume = "vol";
		public const string SeekTitle = "Seek within this song";
		/// <summary>Playback is stalled on a song being rendered — as opposed to the silent
		/// background look-ahead, which nobody needs to be told about.</summary>
		public static string Generating( int n ) => $"generating #{n}…";

		// Seed
		public const string SeedPlaceholder = "tag:n[:genre][:vibe]";
		/// <summary>Typing a seed and being handed a stopped transport is a dead end, so the button
		/// says play, because that is what it does.</summary>
		public const string SeedGo = "play";
		public const string CopySong = "copy seed";
		public const string CopySongTitle = "Copy this song fully written down — the genre and the vibe spelled out, so it plays the same anywhere";
		public const string CopyStation = "copy station";
		public const string CopyStationTitle = "Copy the seed as it stands — whatever it leaves rolling keeps rolling";
		public const string Copied = "copied!";

		// What plays
		public const string Genre = "genre";
		public const string GenreRandom = "Random";
		public const string Reroll = "🎲 reroll";
		public const string RerollTitle = "A fresh station at song 0 — anything you have pinned stays pinned";
		public const string ShuffleOn = "🔀 shuffle: ON";
		public const string ShuffleOff = "🔀 shuffle: OFF";
		public const string ShuffleTitle = "Every next song is a whole new station rather than the next song of this one";
		public const string Tinker = "🎛 tinker";
		public const string TinkerOpen = "hide knobs";

		// The knobs
		public const string VibeHeading = "vibe";
		public const string GlobalHeading = "GLOBAL";
		public const string VibeRoll = "🎲 randomize";
		public const string VibeRollTitle = "Throw every knob somewhere new and keep it — the seed carries these values";
		public const string VibeRandom = "↺ random each song";
		public const string VibeRandomTitle = "Stop pinning these knobs — let every song roll its own again";

		// The playlist
		public const string PlaylistHeading = "playlist";
		public const string JumpTo = "jump to";
		public const string JumpGo = "go";
		public const string RowNow = "now";
		public const string RowReady = "ready";
		public const string RowGone = "gone";
		public const string RowPending = "—";
		public const string Export = "⬇ .wav";
		public const string ExportBusy = "⬇ …";
		public static string ExportTitle( int n ) => $"Export #{n}";

		// What the message line says. A refused seed says so INLINE and changes nothing: a toast
		// that has faded is no help to somebody looking at a board that did nothing.
		public static string Saved( string file ) => $"Saved {file} to your s&box data folder";
		public const string SaveFailed = "Couldn't save song";
		public static string Playing( string seed ) => $"Playing {seed}";
		public const string NewStation = "New station";
		public const string VibeRolled = "Threw every knob but the volumes, and pinned them";
		public const string VibeUnpinned = "Every song rolls its own vibe again — what changes is the songs after this one";
		public const string GenreUnpinned = "Every song rolls its own genre again";
	}
}