Editor/Tool/ArchIcons.cs

Editor UI helper and layout classes for architecture tool icons. ArchIcons locates PNG icon files across projects, provides slug/glyph strings for various architecture enums, and caches found paths. ArchIconGrid and ArchLabeledIconButton implement a 3-column icon grid and labeled icon buttons for the editor palette.

File Access
using System;
using System.Collections.Generic;
using System.IO;
using Editor;
using Sandbox;

namespace Sunless.Architecture;

// Loose PNGs, not Assets - editor chrome, not game content. A dot in the slug makes DrawIcon read a path; no file falls back to a symbol.
public static class ArchIcons
{
	const string Folder = "Editor/Icons";

	static readonly Dictionary<string, string> located = new();

	public static string Get( string slug, string fallback )
	{
		if ( string.IsNullOrWhiteSpace( slug ) )
		{
			return fallback;
		}

		if ( !located.TryGetValue( slug, out var path ) )
		{
			path = Locate( slug );
			located[slug] = path;
		}

		return path ?? fallback;
	}

	public static string RoofStyleSlug( RoofStyle style ) => $"roof_{style}".ToLowerInvariant();

	public static string RoofStyleGlyph( RoofStyle style ) => style switch
	{
		RoofStyle.Flat => "horizontal_rule",
		RoofStyle.Shed => "signal_cellular_4_bar",
		RoofStyle.Gable => "change_history",
		RoofStyle.Hip => "roofing",
		_ => "show_chart"
	};

	public static string RidgeSlug( RidgeRun run ) => $"ridge_{run}".ToLowerInvariant();

	public static string RidgeGlyph( RidgeRun run ) => run switch
	{
		RidgeRun.AlongX => "swap_horiz",
		RidgeRun.AlongY => "swap_vert",
		_ => "auto_fix_high"
	};

	public static string RidgeAdvice( RidgeRun run ) => run switch
	{
		RidgeRun.AlongX => "Ridge along X - flat ends face east and west",
		RidgeRun.AlongY => "Ridge along Y - flat ends face north and south",
		_ => "Auto - ridge down the longer side, flat ends on the short walls"
	};

	public static void Forget()
	{
		located.Clear();
	}

	// Derived from the type name, not a table - a new subtool drops its PNG in beside the others and is
	// picked up with no code, which is what a palette in a library has to be able to do.
	public static string SubtoolSlug( ArchSubtool subtool )
	{
		var name = subtool.GetType().Name;

		if ( name.StartsWith( "Arch", StringComparison.Ordinal ) )
		{
			name = name[4..];
		}

		if ( name.EndsWith( "Subtool", StringComparison.Ordinal ) )
		{
			name = name[..^"Subtool".Length];
		}

		return $"subtool_{name.ToLowerInvariant()}";
	}

	// Kit data is keyed by authored name and falls back to its kind - a new preset needs no art.
	public const string ProfileGlyph = "timeline";

	public static string OpeningGlyph( ArchOpeningPreset preset )
	{
		return Get( $"kind_{preset.Kind}".ToLowerInvariant(), ForKind( preset.Kind ) );
	}

	public static string ProfileSlug( string name )
	{
		return $"profile_{name}";
	}

	public static string PillarSlug( string name )
	{
		return $"pillar_{name}";
	}

	public static string WallSlug( string name )
	{
		return $"wall_{name}";
	}

	static string ForKind( OpeningKind kind ) => kind switch
	{
		OpeningKind.Archway => "door_sliding",
		OpeningKind.Door => "door_front",
		OpeningKind.DoubleDoor => "meeting_room",
		OpeningKind.Garage => "garage",
		OpeningKind.Window => "window",
		OpeningKind.Hatch => "crop_square",
		_ => "crop_square"
	};

	// Every loaded project is asked, not just the one that happens to be open, so a subtool library carries its
	// own art in its own folder and is still found once it peels out of core. Ordered by root so two projects
	// naming one slug resolve the same way every time.
	static string Locate( string slug )
	{
		foreach ( var root in EditorUtility.Projects.GetAll()
			.Select( project => project.GetRootPath() )
			.Where( root => !string.IsNullOrWhiteSpace( root ) )
			.OrderBy( root => root, StringComparer.OrdinalIgnoreCase ) )
		{
			var path = Path.Combine( root, Folder, $"{slug}.png" ).Replace( '\\', '/' );

			if ( File.Exists( path ) )
			{
				return path;
			}
		}

		return null;
	}
}

// Rows seal with a stretch cell as they fill; dispose seals the last row, hence `using`.
public sealed class ArchIconGrid : IDisposable
{
	const int Columns = 3;
	const float Side = 64f;
	const float Height = 68f;
	const float Glyph = 30f;

	readonly Layout parent;
	readonly List<IconButton> exclusive = new();

	Layout row;
	int placed;

	ArchIconGrid( Layout parent )
	{
		this.parent = parent;
	}

	public static ArchIconGrid In( Layout parent )
	{
		return new ArchIconGrid( parent );
	}

	public void Dispose()
	{
		Seal();
	}

	public IconButton Toggle( string tooltip, string slug, string fallback, bool value, Action<bool> assign )
	{
		var button = Place( tooltip, ArchIcons.Get( slug, fallback ), Caption( tooltip, slug ) );

		button.IsToggle = true;
		button.IsActive = value;
		button.OnToggled = assign;

		return button;
	}

	public IconButton Pick( string tooltip, string slug, string fallback, bool active, Action chosen )
	{
		return Icon( tooltip, ArchIcons.Get( slug, fallback ), Caption( tooltip, slug ), active, chosen );
	}

	// For a caller that already owns its icon answer rather than a slug and a fallback.
	public IconButton Icon( string tooltip, string icon, bool active, Action chosen )
	{
		return Icon( tooltip, icon, Caption( tooltip, null ), active, chosen );
	}

	IconButton Icon( string tooltip, string icon, string caption, bool active, Action chosen )
	{
		var button = Place( tooltip, icon, caption );

		button.IsActive = active;
		exclusive.Add( button );

		button.OnClick = () =>
		{
			foreach ( var other in exclusive )
			{
				other.IsActive = other == button;
			}

			chosen?.Invoke();
		};

		return button;
	}

	IconButton Place( string tooltip, string icon, string caption )
	{
		var button = new ArchLabeledIconButton( icon, caption )
		{
			ToolTip = tooltip,
			IconSize = Glyph,
			FixedWidth = Side,
			FixedHeight = Height
		};

		if ( placed % Columns == 0 )
		{
			Seal();

			row = parent.AddRow();
			row.Spacing = 4;
		}

		row.Add( button );
		placed++;

		return button;
	}

	static string Caption( string tooltip, string slug )
	{
		var described = tooltip.Split( " — ", StringSplitOptions.TrimEntries );

		if ( described.Length > 1 )
		{
			return Compact( described[0] );
		}

		if ( !string.IsNullOrWhiteSpace( slug ) )
		{
			var slugWords = Path.GetFileNameWithoutExtension( slug )
				.Split( '_', StringSplitOptions.RemoveEmptyEntries );

			if ( slugWords.Length > 0 )
			{
				return Title( slugWords[^1] );
			}
		}

		return Compact( tooltip );
	}

	static string Compact( string text )
	{
		var caption = text
			.Replace( "Where you drag", "Surface", StringComparison.OrdinalIgnoreCase )
			.Replace( " under it", "", StringComparison.OrdinalIgnoreCase )
			.Replace( " over it", "", StringComparison.OrdinalIgnoreCase )
			.Trim();

		if ( caption.Length <= 10 )
		{
			return caption;
		}

		var words = caption.Split( ' ', StringSplitOptions.RemoveEmptyEntries );

		return words.Length > 0 ? words[0] : caption;
	}

	static string Title( string caption ) => caption.Length == 0
		? caption
		: char.ToUpperInvariant( caption[0] ) + caption[1..];

	void Seal()
	{
		if ( row is null )
		{
			return;
		}

		row.AddStretchCell();
		row = null;
	}
}

sealed class ArchLabeledIconButton : IconButton
{
	readonly string caption;

	public ArchLabeledIconButton( string icon, string caption ) : base( icon )
	{
		this.caption = caption;
	}

	protected override void OnPaint()
	{
		Paint.ClearBrush();
		Paint.ClearPen();

		var active = Enabled && IsActive;
		var background = active ? BackgroundActive : Background;
		var foreground = active ? ForegroundActive : Foreground;

		Paint.SetBrush( background );
		Paint.DrawRect( LocalRect, 2f );

		Paint.ClearBrush();
		Paint.ClearPen();

		Paint.Pen = foreground.WithAlphaMultiplied( Paint.HasMouseOver ? 1f : 0.9f );

		if ( !Enabled )
		{
			Paint.Pen = foreground.WithAlphaMultiplied( 0.25f );
		}

		Paint.DrawIcon( new Rect( 0f, 4f, Width, 37f ), Icon, IconSize, TextFlag.Center );

		var fontSize = caption.Length > 9 ? 7f : caption.Length > 7 ? 7.5f : 8f;

		Paint.SetDefaultFont( fontSize, 600 );
		Paint.DrawText( new Rect( 3f, 42f, Width - 6f, 20f ), caption, TextFlag.Center );
	}
}