Editor/Tilemap/TilemapTilesetPickerWidget.cs

An editor UI widget that lists project TilesetResource assets, shows thumbnails, name, brush type and path, allows hovering and selecting a tileset, and ensures project tileset GUIDs are unique by regenerating and saving assets when needed.

File AccessNetworking
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
using Saandy.Tilemapper;

namespace Saandy.Editor.Tilemapper;

public sealed class TilemapTilesetPickerWidget : Widget
{
	private sealed class TilesetEntry
	{
		public TilesetResource Tileset;
		public Asset Asset;
		public Pixmap Thumbnail;
		public string Name;
		public string ResourcePath;
	}

	private sealed class ProjectTilesetEntry
	{
		public TilesetResource Tileset;
		public Asset Asset;
		public string ResourcePath;
	}

	private const float HeaderHeight = 34.0f;
	private const float RowHeight = 74.0f;
	private const float Padding = 8.0f;
	private const float ThumbSize = 52.0f;

	private readonly List<TilesetEntry> _entries = new();
	private TilesetResource _selectedTileset;
	private string _selectedResourcePath;
	private int _hoveredIndex = -1;

	public Action<TilesetResource> OnTilesetSelected { get; set; }

	public TilemapTilesetPickerWidget( Widget parent ) : base( parent )
	{
		MouseTracking = true;
		HorizontalSizeMode = SizeMode.Flexible;
		VerticalSizeMode = SizeMode.CanGrow;
		MinimumHeight = 420;

		RefreshTilesets();
	}

	public void RefreshTilesets()
	{
		_entries.Clear();

		var projectTilesets = GetProjectTilesetEntries();
		EnsureUniqueProjectTilesetGuids( projectTilesets );

		foreach ( var projectEntry in projectTilesets )
		{
			var tileset = projectEntry.Tileset;
			var asset = projectEntry.Asset;
			string resourcePath = projectEntry.ResourcePath;

			_entries.Add( new TilesetEntry
			{
				Tileset = tileset,
				Asset = asset,
				Thumbnail = asset.GetAssetThumb( true ),
				Name = GetTilesetDisplayName( tileset, asset, resourcePath ),
				ResourcePath = resourcePath
			} );
		}

		_entries.Sort( ( a, b ) => string.Compare( a.Name, b.Name, StringComparison.OrdinalIgnoreCase ) );

		MinimumHeight = Math.Max( 220.0f, HeaderHeight + Padding + _entries.Count * RowHeight + Padding );

		Update();
	}

	public void SetSelectedTileset( TilesetResource tileset )
	{
		_selectedTileset = tileset;
		_selectedResourcePath = GetTilesetResourcePath( tileset );
		Update();
	}

	protected override void OnMouseMove( MouseEvent e )
	{
		base.OnMouseMove( e );

		_hoveredIndex = GetEntryIndexAtPosition( e.LocalPosition );
		Update();
	}

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

		_hoveredIndex = -1;
		Update();
	}

	protected override void OnMousePress( MouseEvent e )
	{
		base.OnMousePress( e );

		if ( !e.LeftMouseButton )
			return;

		int index = GetEntryIndexAtPosition( e.LocalPosition );

		if ( index < 0 || index >= _entries.Count )
			return;

		var entry = _entries[index];

		if ( entry?.Tileset == null || !entry.Tileset.IsValid() )
			return;

		SetSelectedTileset( entry.Tileset );
		OnTilesetSelected?.Invoke( entry.Tileset );
	}

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

		Paint.ClearPen();
		Paint.SetBrush( Color.Black.WithAlpha( 0.18f ) );
		Paint.DrawRect( LocalRect );

		DrawHeader();

		if ( _entries.Count == 0 )
		{
			Paint.SetPen( Color.White.WithAlpha( 0.75f ) );
			Paint.DrawText(
				new Rect( Padding, HeaderHeight + Padding, LocalRect.Width - Padding * 2.0f, 80.0f ),
				"No TilesetResource assets found. Save a .tileset asset, then press Refresh Tilesets.",
				TextFlag.LeftTop
			);
			return;
		}

		for ( int i = 0; i < _entries.Count; i++ )
		{
			DrawEntry( i, _entries[i] );
		}
	}

	private void DrawHeader()
	{
		Rect headerRect = new Rect( 0.0f, 0.0f, LocalRect.Width, HeaderHeight );

		Paint.ClearPen();
		Paint.SetBrush( Color.Black.WithAlpha( 0.30f ) );
		Paint.DrawRect( headerRect );

		Paint.SetPen( Color.White );
		Paint.DrawText(
			new Rect( Padding, headerRect.Top, Math.Max( 1.0f, headerRect.Width - Padding * 2.0f ), headerRect.Height ),
			$"Tilesets ({_entries.Count})",
			TextFlag.LeftCenter
		);
	}

	private void DrawEntry( int index, TilesetEntry entry )
	{
		Rect row = GetEntryRect( index );

		bool selected = IsSelected( entry );
		bool hovered = index == _hoveredIndex;

		Color background = selected
			? Color.Yellow.WithAlpha( 0.24f )
			: hovered
				? Color.White.WithAlpha( 0.09f )
				: Color.White.WithAlpha( 0.035f );

		Color border = selected
			? Color.Yellow
			: Color.White.WithAlpha( 0.16f );

		Paint.SetBrush( background );
		Paint.SetPen( border, selected ? 2.0f : 1.0f );
		Paint.DrawRect( row );

		Rect thumbRect = new Rect(
			row.Left + Padding,
			row.Top + (row.Height - ThumbSize) * 0.5f,
			ThumbSize,
			ThumbSize
		);

		DrawThumbnail( entry, thumbRect );

		float textX = thumbRect.Right + Padding;
		float textW = Math.Max( 1.0f, row.Right - textX - Padding );

		Rect nameRect = new Rect( textX, row.Top + 8.0f, textW, 20.0f );
		Rect brushRect = new Rect( textX, row.Top + 30.0f, textW, 18.0f );
		Rect pathRect = new Rect( textX, row.Top + 50.0f, textW, 16.0f );

		Paint.SetPen( selected ? Color.Yellow : Color.White );
		Paint.DrawText( nameRect, entry.Name ?? "Tileset", TextFlag.LeftCenter );

		string brushName = TileBrush.GetBrush( entry.Tileset?.BrushType ?? BrushType.None )?.Name ?? "No Brush";
		Paint.SetPen( Color.White.WithAlpha( 0.70f ) );
		Paint.DrawText( brushRect, brushName, TextFlag.LeftCenter );

		Paint.SetPen( Color.White.WithAlpha( 0.45f ) );
		Paint.DrawText( pathRect, entry.ResourcePath ?? "", TextFlag.LeftCenter );
	}

	private void DrawThumbnail( TilesetEntry entry, Rect rect )
	{
		Paint.ClearPen();
		Paint.SetBrush( Color.Black.WithAlpha( 0.45f ) );
		Paint.DrawRect( rect );

		if ( entry?.Thumbnail == null )
		{
			Paint.SetPen( Color.White.WithAlpha( 0.45f ) );
			Paint.DrawText( rect, "?", TextFlag.Center );
			return;
		}

		DrawPixmapInsideRect( entry.Thumbnail, rect, 4.0f );
	}

	private void DrawPixmapInsideRect( Pixmap pixmap, Rect rect, float padding )
	{
		if ( pixmap == null )
			return;

		float availableW = Math.Max( 1.0f, rect.Width - padding * 2.0f );
		float availableH = Math.Max( 1.0f, rect.Height - padding * 2.0f );

		float scale = Math.Min(
			availableW / Math.Max( 1.0f, pixmap.Width ),
			availableH / Math.Max( 1.0f, pixmap.Height )
		);

		// Large thumbnails must shrink into the square. Small thumbnails may scale up a bit.
		scale = Math.Max( 0.01f, scale );

		float w = pixmap.Width * scale;
		float h = pixmap.Height * scale;

		Rect drawRect = new Rect(
			rect.Left + (rect.Width - w) * 0.5f,
			rect.Top + (rect.Height - h) * 0.5f,
			w,
			h
		);

		Paint.Draw( drawRect, pixmap );
	}

	private int GetEntryIndexAtPosition( Vector2 position )
	{
		if ( position.y < HeaderHeight + Padding )
			return -1;

		int index = (int)MathF.Floor( (position.y - HeaderHeight - Padding) / RowHeight );

		if ( index < 0 || index >= _entries.Count )
			return -1;

		Rect row = GetEntryRect( index );

		if ( !row.IsInside( position ) )
			return -1;

		return index;
	}

	private Rect GetEntryRect( int index )
	{
		return new Rect(
			Padding,
			HeaderHeight + Padding + index * RowHeight,
			Math.Max( 1.0f, LocalRect.Width - Padding * 2.0f ),
			RowHeight - 6.0f
		);
	}

	private bool IsSelected( TilesetEntry entry )
	{
		if ( entry == null )
			return false;

		string entryPath = NormalizePath( entry.ResourcePath );
		string selectedPath = NormalizePath( _selectedResourcePath );

		if ( !string.IsNullOrWhiteSpace( entryPath ) && !string.IsNullOrWhiteSpace( selectedPath ) )
			return entryPath == selectedPath;

		return entry.Tileset == _selectedTileset;
	}


	public static TilesetResource GetFirstProjectTileset()
	{
		var projectTilesets = GetProjectTilesetEntries();
		EnsureUniqueProjectTilesetGuids( projectTilesets );
		return projectTilesets.FirstOrDefault()?.Tileset;
	}

	private static List<ProjectTilesetEntry> GetProjectTilesetEntries()
	{
		List<ProjectTilesetEntry> result = new();
		HashSet<string> seenResourcePaths = new();

		foreach ( var tileset in ResourceLibrary.GetAll<TilesetResource>() )
		{
			if ( tileset == null || !tileset.IsValid() )
				continue;

			string resourcePath = GetTilesetResourcePath( tileset );
			string normalizedPath = NormalizePath( resourcePath );

			if ( string.IsNullOrWhiteSpace( normalizedPath ) )
				continue;

			if ( !normalizedPath.EndsWith( ".tileset", StringComparison.OrdinalIgnoreCase ) )
				continue;

			if ( !seenResourcePaths.Add( normalizedPath ) )
				continue;

			Asset asset = FindAsset( resourcePath );

			// If the resource does not resolve to an asset-browser entry, it is probably
			// a stale/deleted/cached resource. Do not show it in the painter list.
			if ( asset == null )
				continue;

			result.Add( new ProjectTilesetEntry
			{
				Tileset = tileset,
				Asset = asset,
				ResourcePath = resourcePath
			} );
		}

		result.Sort( ( a, b ) => string.Compare(
			NormalizePath( a.ResourcePath ),
			NormalizePath( b.ResourcePath ),
			StringComparison.OrdinalIgnoreCase ) );

		return result;
	}

	private static void EnsureUniqueProjectTilesetGuids( List<ProjectTilesetEntry> projectTilesets )
	{
		if ( projectTilesets == null )
			return;

		HashSet<Guid> usedGuids = new();

		foreach ( var entry in projectTilesets )
		{
			if ( entry?.Tileset == null || !entry.Tileset.IsValid() || entry.Asset == null )
				continue;

			bool changed = false;

			if ( entry.Tileset.TilesetGuid == Guid.Empty || usedGuids.Contains( entry.Tileset.TilesetGuid ) )
			{
				do
				{
					entry.Tileset.RegenerateTilesetGuid();
				}
				while ( entry.Tileset.TilesetGuid == Guid.Empty || usedGuids.Contains( entry.Tileset.TilesetGuid ) );

				changed = true;
			}

			usedGuids.Add( entry.Tileset.TilesetGuid );

			if ( changed )
			{
				entry.Asset.SaveToDisk( entry.Tileset );
			}
		}
	}

	private static string GetTilesetDisplayName( TilesetResource tileset, Asset asset, string resourcePath )
	{
		if ( asset != null && !string.IsNullOrWhiteSpace( asset.Name ) )
			return asset.Name;

		if ( !string.IsNullOrWhiteSpace( resourcePath ) )
		{
			string fileName = System.IO.Path.GetFileNameWithoutExtension( resourcePath );

			if ( !string.IsNullOrWhiteSpace( fileName ) )
				return fileName;
		}

		return tileset?.FilePath ?? "Tileset";
	}

	private static Asset FindAsset( string resourcePath )
	{
		if ( string.IsNullOrWhiteSpace( resourcePath ) )
			return null;

		return AssetSystem.FindByPath( resourcePath );
	}

	private static string GetTilesetResourcePath( TilesetResource tileset )
	{
		if ( tileset == null )
			return string.Empty;

		return tileset.ResourcePath ?? string.Empty;
	}

	private static string NormalizePath( string path )
	{
		return (path ?? string.Empty)
			.Replace( '\\', '/' )
			.Trim()
			.ToLowerInvariant();
	}
}