Editor/UI/ArchTile.cs

UI editor code for an arch tile browser. ArchThumb loads tile thumbnail images from project or an index path and caches them. ArchTile is a clickable UI widget that draws a tile image, badges, glyphs and selection/hover states. ArchTileGrid lays out sections of tiles into a responsive grid and manages scrolling.

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

namespace Sunless.Architecture;

public static class ArchThumb
{
	const string IndexTiles = "E:/GameDev/_LIT/mega-scans/_index/tiles";

	static readonly Dictionary<string, Pixmap> loaded = new();
	static readonly HashSet<string> absent = new();

	public static Pixmap For( string category, string name, string id )
	{
		var key = $"{category}|{name}|{id}";

		if ( loaded.TryGetValue( key, out var hit ) )
		{
			return hit;
		}

		if ( absent.Contains( key ) )
		{
			return null;
		}

		var pixmap = Load( StagedColour( category, name ) ) ?? Load( IndexTile( id ) );

		if ( pixmap is null )
		{
			absent.Add( key );
			return null;
		}

		loaded[key] = pixmap;
		return pixmap;
	}

	public static void Forget()
	{
		loaded.Clear();
		absent.Clear();
	}

	static string StagedColour( string category, string name )
	{
		if ( string.IsNullOrWhiteSpace( category ) || string.IsNullOrWhiteSpace( name ) )
		{
			return null;
		}

		var root = Project.Current?.GetRootPath();

		if ( string.IsNullOrWhiteSpace( root ) )
		{
			return null;
		}

		return Path.Combine( root, "Assets", "environment", "seamless", category, name, $"{name}_color.png" );
	}

	static string IndexTile( string id )
	{
		return string.IsNullOrWhiteSpace( id ) ? null : Path.Combine( IndexTiles, $"{id}.jpg" );
	}

	static Pixmap Load( string path )
	{
		if ( string.IsNullOrWhiteSpace( path ) || !File.Exists( path ) )
		{
			return null;
		}

		try
		{
			return Pixmap.FromFile( path );
		}
		catch ( Exception )
		{
			return null;
		}
	}
}

public sealed class ArchTile : Widget
{
	public Action Clicked { get; set; }
	public Action Opened { get; set; }
	public Func<bool> Current { get; set; }

	// Art that is generated on demand arrives frames after the tile does, so it is pulled rather than passed.
	public Func<Pixmap> Source { get; set; }

	// What the tile itself can do, on its own right-click - never a chip acting on whatever happens to be chosen.
	public Action<ContextMenu> Menu { get; set; }

	public string Badge { get; set; }
	public Action<Rect> Glyph { get; set; }
	public float Dim { get; set; } = 1f;
	public float LabelStrip { get; set; } = 34f;

	// Washed over the art: a blockout role is the same grid, told apart by hue.
	public Color? Fill { get; set; }

	readonly Pixmap image;
	readonly string title;
	readonly string detail;
	readonly bool warn;

	public ArchTile( Widget parent, Pixmap image, string title, string detail, bool warn, Vector2 size ) : base( parent )
	{
		this.image = image;
		this.title = title ?? "";
		this.detail = detail ?? "";
		this.warn = warn;

		Cursor = CursorShape.Finger;
		FixedSize = size;
	}

	// A hotload cannot always re-map a captured lambda, and the orphan it leaves throws on every paint
	// until the panel that made it is rebuilt. Dropping it once turns a permanent exception storm into a
	// placeholder that the next sidebar rebuild fills back in.
	Pixmap Art()
	{
		if ( Source is null )
		{
			return null;
		}

		try
		{
			return Source.Invoke();
		}
		catch ( NotImplementedException )
		{
			Source = null;
			return null;
		}
	}

	bool Selected()
	{
		if ( Current is null )
		{
			return false;
		}

		try
		{
			return Current();
		}
		catch ( NotImplementedException )
		{
			Current = null;
			return false;
		}
	}

	protected override void OnPaint()
	{
		var selected = Selected();
		var hover = Paint.HasMouseOver;

		var body = LocalRect;
		var art = body.Shrink( 0, 0, 0, LabelStrip );
		var picture = Art() ?? image;

		Paint.ClearPen();
		Paint.SetBrush( Theme.ControlBackground );
		Paint.DrawRect( body, 3 );

		if ( picture is not null )
		{
			Paint.Draw( art, picture, (hover || selected ? 1.0f : 0.88f) * Dim );
		}
		else if ( Fill is null )
		{
			Paint.SetBrush( Color.FromRgb( 0x2A2A2E ) );
			Paint.DrawRect( art, 3 );
			Paint.SetPen( Theme.TextControl.WithAlpha( 0.35f ) );
			Paint.DrawIcon( art, Source is null ? "broken_image" : "hourglass_empty", 22 );
		}

		if ( Fill is { } wash )
		{
			Paint.ClearPen();
			Paint.SetBrush( wash.WithAlpha( picture is null ? 1f : 0.62f ) );
			Paint.DrawRect( art, 3 );
		}

		if ( Glyph is not null )
		{
			var side = Math.Max( 22f, art.Width * 0.16f );
			var badge = new Rect( art.Right - side - 6, art.Top + 6, side, side );

			Paint.ClearPen();
			Paint.SetBrush( Color.Black.WithAlpha( 0.62f ) );
			Paint.DrawRect( badge, 4 );

			Glyph( badge.Shrink( side * 0.16f ) );
		}

		if ( !string.IsNullOrWhiteSpace( Badge ) )
		{
			var text = Badge.ToUpperInvariant();
			var width = Math.Min( art.Width * 0.72f, text.Length * 5.9f + 12f );
			var chip = new Rect( art.Left + 6, art.Top + 6, width, 18 );

			Paint.ClearPen();
			Paint.SetBrush( Theme.Primary.WithAlpha( 0.94f ) );
			Paint.DrawRect( chip, 3 );

			Paint.SetDefaultFont( 7.5f, 700 );
			Paint.SetPen( Color.White );
			Paint.DrawText( chip, text );
		}

		var label = body.Shrink( 6, 0, 6, 0 );
		label.Top = art.Bottom + 2;

		Paint.SetDefaultFont( 7.5f, 600 );
		Paint.SetPen( warn ? Theme.Yellow : Color.White );
		Paint.DrawText( label, title, TextFlag.LeftTop );

		Paint.SetDefaultFont( 7f );
		Paint.SetPen( Theme.TextControl.WithAlpha( 0.6f ) );
		Paint.DrawText( label.Shrink( 0, 14, 0, 0 ), detail, TextFlag.LeftTop );

		// An outline and a small mark, never a wash - the object has to stay readable while it is chosen.
		if ( selected )
		{
			Paint.ClearBrush();
			Paint.SetPen( Theme.Primary, 2 );
			Paint.DrawRect( body.Shrink( 1 ), 3 );

			var mark = new Rect( art.Right - 20f, art.Bottom - 20f, 16f, 16f );

			Paint.ClearPen();
			Paint.SetBrush( Theme.Primary );
			Paint.DrawRect( mark, 8 );

			Paint.SetPen( Color.White );
			Paint.DrawIcon( mark, "check", 11 );
		}
		else if ( hover )
		{
			Paint.ClearBrush();
			Paint.SetPen( Color.White.WithAlpha( 0.35f ), 1 );
			Paint.DrawRect( body.Shrink( 0.5f ), 3 );
		}
	}

	protected override void OnMouseClick( MouseEvent e )
	{
		if ( e.LeftMouseButton )
		{
			Clicked?.Invoke();
		}
	}

	protected override void OnDoubleClick( MouseEvent e )
	{
		if ( !e.LeftMouseButton )
		{
			return;
		}

		Clicked?.Invoke();
		Opened?.Invoke();
	}

	protected override void OnContextMenu( ContextMenuEvent e )
	{
		if ( Menu is null )
		{
			return;
		}

		var menu = new ContextMenu( this );

		Menu( menu );
		menu.OpenAtCursor( false );

		e.Accepted = true;
	}

	protected override void OnMouseEnter()
	{
		Update();
	}

	protected override void OnMouseLeave()
	{
		Update();
	}
}

public static class ArchTileSize
{
	public static readonly Vector2 Preview = new( 276, 320 );
	public static readonly Vector2 Role = new( 268, 300 );

	// Two columns inside a 240px tool sidebar, and four or more inside the expanded modal.
	public static readonly Vector2 Card = new( 86, 104 );
	public static readonly Vector2 Browsed = new( 196, 224 );
}

public sealed class ArchTileSection
{
	public string Title { get; set; }
	public string Note { get; set; }
	public List<Func<Widget, Widget>> Tiles { get; } = new();
}

public sealed class ArchTileGrid : Widget
{
	sealed class Placed
	{
		public List<Widget> Headers { get; init; }
		public List<Widget> Tiles { get; init; }
	}

	readonly ScrollArea scroll;
	readonly Widget canvas;
	readonly float padding;
	readonly List<Placed> placed = new();

	Vector2 tileSize;
	int columns;

	public ArchTileGrid( Widget parent, Vector2 tileSize, float padding = 8f ) : base( parent )
	{
		this.tileSize = tileSize;
		this.padding = padding;

		Layout = Layout.Column();

		scroll = new ScrollArea( this );
		scroll.HorizontalScrollbarMode = ScrollbarMode.Off;

		canvas = new Widget( scroll ) { Layout = Layout.Column() };

		// Extra at the foot: the last row scrolls flush against the edge and its caption is clipped by it.
		canvas.Layout.Margin = new Sandbox.UI.Margin( padding, padding, padding, padding + 10f );
		canvas.Layout.Spacing = padding * 0.75f;

		scroll.Canvas = canvas;
		Layout.Add( scroll );
	}

	public Vector2 TileSize => tileSize;

	public ScrollArea Scroller => scroll;

	// A sidebar browser divides its width into a fixed column count rather than fitting a fixed tile into
	// it, so the cards reach both edges instead of leaving a column's worth of dead space on the right.
	public void Retile( Vector2 size )
	{
		tileSize = size;
	}

	public void Set( IEnumerable<ArchTileSection> sections )
	{
		placed.Clear();
		canvas.Layout.Clear( true );

		foreach ( var section in sections )
		{
			var headers = new List<Widget>();

			if ( !string.IsNullOrWhiteSpace( section.Title ) )
			{
				var caption = new Label( section.Title, canvas );
				caption.SetStyles( "font-size: 11px; font-weight: 700; color: rgba(255,255,255,0.85); margin-top: 8px;" );
				headers.Add( caption );

				if ( !string.IsNullOrWhiteSpace( section.Note ) )
				{
					var note = new Label( section.Note, canvas );
					note.SetStyles( "font-size: 10px; color: rgba(255,255,255,0.4); margin-bottom: 2px;" );
					note.WordWrap = true;
					headers.Add( note );
				}
			}

			placed.Add( new Placed
			{
				Headers = headers,
				Tiles = section.Tiles.Select( builder => builder( canvas ) ).ToList()
			} );
		}

		Arrange();
	}

	// Clear(false) re-adds the SAME widgets; fresh Labels would linger on the canvas at stale positions.
	void Arrange()
	{
		canvas.Layout.Clear( false );

		var fit = Fit();

		foreach ( var section in placed )
		{
			if ( section.Tiles.Count == 0 )
			{
				continue;
			}

			foreach ( var header in section.Headers )
			{
				canvas.Layout.Add( header );
			}

			var rows = new List<Layout>();
			Layout row = null;

			for ( var i = 0; i < section.Tiles.Count; i++ )
			{
				if ( i % fit == 0 )
				{
					row = canvas.Layout.AddRow();
					row.Spacing = padding;
					rows.Add( row );
				}

				row.Add( section.Tiles[i] );
			}

			foreach ( var trailing in rows )
			{
				trailing.AddStretchCell();
			}
		}

		canvas.Layout.AddStretchCell();
		columns = fit;
	}

	int Fit()
	{
		var usable = Math.Max( Size.x - padding * 2f - 8f, tileSize.x );
		return Math.Max( 1, (int)((usable + padding) / (tileSize.x + padding)) );
	}

	protected override void OnResize()
	{
		base.OnResize();

		if ( placed.Count > 0 && Fit() != columns )
		{
			Arrange();
		}
	}
}