Editor/Tileset/TilesetRectOrderWidget.cs

Editor UI widget for a tileset rect/order tool. It displays the tileset source image, allows dragging to define tile rects, selecting and dragging existing tiles onto an optional template, and reorders/assigns tiles according to a template.

File Access
using Editor;
using Sandbox;
using Saandy.Tilemapper;
using System;
using System.Collections.Generic;

namespace Saandy.Editor.Tilemapper;

public sealed class TilesetRectOrderWidget : Widget
{
	private readonly TilesetResource _tileset;
	private readonly TileBrushEditorTemplate _template;
	private readonly Action _onChanged;

	private Texture _sourceTexture;
	private Pixmap _sourcePixmap;

	private Texture _templateTexture;
	private Pixmap _templatePixmap;
	private readonly Pixmap[] _templateSlotPixmaps;

	private readonly Dictionary<Guid, Pixmap> _tileSpritePixmapCache = new();

	private Vector2Int _sourceTextureSize = Vector2Int.One;

	private Rect _sourceImageRect;
	private Rect _templateRect;
	private Rect _tileDataRect;

	private TilesetTileDataWidget _tileDataWidget;

	private bool _draggingNewRect;
	private Vector2 _rectDragStart;
	private Vector2 _rectDragEnd;
	private Rect _pendingSourceRect;

	private int _selectedTileIndex = -1;

	private bool _draggingExistingTile;
	private int _draggedTileIndex = -1;
	private Vector2 _dragMousePosition;
	private Vector2 _mouseLocalPosition;

	// template slot -> tile Guid
	private readonly Guid[] _templateSlotIds;

	private string _statusText = "";

	private int SlotCount => Math.Max( 0, _template?.SlotCount ?? 0 );
	private int TemplateColumns => Math.Max( 1, _template?.Columns ?? 1 );
	private int TemplateRows => Math.Max( 1, _template?.Rows ?? 1 );
	private int TemplateSourceColumns => Math.Max( 1, _template?.TemplateSourceColumns ?? TemplateColumns );
	private int TemplateSourceRows => Math.Max( 1, _template?.TemplateSourceRows ?? TemplateRows );
	public bool HasTemplate => _template != null && _template.HasTemplate;

	public TilesetRectOrderWidget( TilesetResource tileset, TileBrushEditorTemplate template, Action onChanged ) : base( null )
	{
		_tileset = tileset;
		_template = template;
		_onChanged = onChanged;

		_templateSlotIds = new Guid[SlotCount];
		_templateSlotPixmaps = new Pixmap[SlotCount];

		MinimumHeight = HasTemplate
			? Math.Max( 760, 160 + TemplateRows * 96 )
			: 560;

		FixedHeight = MinimumHeight;
		MinimumWidth = 560;
		MouseTracking = true;

		HorizontalSizeMode = SizeMode.Flexible;
		VerticalSizeMode = SizeMode.CanGrow;

		_tileDataWidget = new TilesetTileDataWidget( this, _tileset, _onChanged );
		_tileDataWidget.SetSelectedTileIndex( _selectedTileIndex );

		Refresh();
		LoadTemplateFromCurrentOrder();
	}

	public void Refresh()
	{
		_sourceTexture = _tileset?.GetTexture();
		_sourceTextureSize = _tileset?.GetTextureSize() ?? Vector2Int.One;

		_sourcePixmap = null;
		_tileSpritePixmapCache.Clear();

		if ( _sourceTexture != null && _sourceTexture.IsValid )
		{
			_sourcePixmap = Pixmap.FromTexture( _sourceTexture, true );
		}

		LoadTemplateTexture();
		RemoveInvalidTemplateAssignments();

		UpdateStatus();
		_tileDataWidget?.RefreshFromData();
		Update();
	}

	private void LoadTemplateTexture()
	{
		_templateTexture = null;
		_templatePixmap = null;

		for ( int i = 0; i < _templateSlotPixmaps.Length; i++ )
		{
			_templateSlotPixmaps[i] = null;
		}

		if ( !HasTemplate )
			return;

		if ( string.IsNullOrWhiteSpace( _template.TemplateTexturePath ) )
			return;

		_templateTexture = Texture.Load( _template.TemplateTexturePath );

		if ( _templateTexture == null || !_templateTexture.IsValid )
			return;

		_templatePixmap = Pixmap.FromTexture( _templateTexture, true );

		if ( _templatePixmap == null )
			return;

		BuildTemplateSlotPixmaps();
	}

	private void BuildTemplateSlotPixmaps()
	{
		if ( _templatePixmap == null )
			return;

		int cellW = Math.Max( 1, _templatePixmap.Width / TemplateSourceColumns );
		int cellH = Math.Max( 1, _templatePixmap.Height / TemplateSourceRows );

		for ( int i = 0; i < SlotCount; i++ )
		{
			int cellX = i % TemplateSourceColumns;
			int cellY = i / TemplateSourceColumns;

			var slotPixmap = new Pixmap( cellW, cellH );
			slotPixmap.Clear( Color.Transparent );

			using ( Paint.ToPixmap( slotPixmap ) )
			{
				Paint.ClearPen();
				Paint.SetBrush( Color.Transparent );
				Paint.DrawRect( new Rect( 0, 0, cellW, cellH ) );

				Paint.Draw(
					new Rect(
						-cellX * cellW,
						-cellY * cellH,
						_templatePixmap.Width,
						_templatePixmap.Height
					),
					_templatePixmap
				);
			}

			_templateSlotPixmaps[i] = slotPixmap;
		}
	}

	public void ClearTemplate()
	{
		for ( int i = 0; i < _templateSlotIds.Length; i++ )
		{
			_templateSlotIds[i] = Guid.Empty;
		}

		_selectedTileIndex = -1;
		_tileDataWidget?.SetSelectedTileIndex( _selectedTileIndex );
		_draggedTileIndex = -1;
		_pendingSourceRect = default;

		UpdateStatus();
		Update();
	}

	public void LoadTemplateFromCurrentOrder()
	{
		for ( int i = 0; i < _templateSlotIds.Length; i++ )
		{
			_templateSlotIds[i] = Guid.Empty;
		}

		if ( HasTemplate && _tileset?.Tiles != null )
		{
			for ( int i = 0; i < SlotCount && i < _tileset.Tiles.Count; i++ )
			{
				var tile = _tileset.Tiles[i];

				if ( tile == null )
					continue;

				_templateSlotIds[i] = tile.Id;
			}
		}

		UpdateStatus();
		Update();
	}

	private void UpdateStatus()
	{
		if ( _tileset == null )
		{
			_statusText = "No tileset.";
			return;
		}

		if ( _sourceTexture == null || !_sourceTexture.IsValid )
		{
			_statusText = "No valid tileset image. Set Tileset Image first.";
			return;
		}

		string selected = _selectedTileIndex >= 0
			? $"Selected rect: {_selectedTileIndex}"
			: "Selected rect: none";

		string pending = _pendingSourceRect.Width > 0 && _pendingSourceRect.Height > 0
			? $"Pending rect: {_pendingSourceRect.Left:0},{_pendingSourceRect.Top:0} {_pendingSourceRect.Width:0}x{_pendingSourceRect.Height:0}"
			: "Pending rect: none";

		if ( !HasTemplate )
		{
			_statusText =
				$"Image: {_sourceTextureSize.x}x{_sourceTextureSize.y}  |  Rects: {_tileset.Tiles?.Count ?? 0}  |  No autotile template  |  {selected}  |  {pending}";
			return;
		}

		int assignedSlots = 0;

		for ( int i = 0; i < _templateSlotIds.Length; i++ )
		{
			if ( _templateSlotIds[i] != Guid.Empty )
				assignedSlots++;
		}

		string templateStatus = _templatePixmap != null
			? "Template: loaded"
			: "Template: missing";

		_statusText =
			$"Image: {_sourceTextureSize.x}x{_sourceTextureSize.y}  |  Rects: {_tileset.Tiles?.Count ?? 0}  |  Template slots: {assignedSlots}/{SlotCount}  |  {templateStatus}  |  {selected}  |  {pending}";
	}

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

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

		CalculateLayoutRects();

		DrawTitles();
		DrawSourceImage();
		DrawTemplate();
		DrawStatusText();
		DrawDraggedTileOverlay();
	}

	private void CalculateLayoutRects()
	{
		float top = 8.0f;
		float padding = 8.0f;
		float gap = 18.0f;
		float statusHeight = 24.0f;
		float bottomReserved = padding + statusHeight + 8.0f;

		float width = Math.Max( 1.0f, LocalRect.Width - padding * 2.0f );

		float leftWidth = HasTemplate ? width * 0.56f : width;
		float rightWidth = HasTemplate ? width - leftWidth - gap : 0.0f;

		float texW = Math.Max( 1, _sourceTextureSize.x );
		float texH = Math.Max( 1, _sourceTextureSize.y );

		float sourceMaxW = leftWidth;
		float dataPanelHeight = Math.Max( 96.0f, _tileDataWidget?.PreferredContentHeight ?? 154.0f );
		float dataPanelReservedHeight = dataPanelHeight + 24.0f;
		float sourceMaxH = Math.Max( 1.0f, LocalRect.Height - top - 28.0f - dataPanelReservedHeight - bottomReserved - 24.0f );

		float sourceScale = Math.Min( sourceMaxW / texW, sourceMaxH / texH );

		if ( sourceScale > 1.0f )
			sourceScale = MathF.Floor( sourceScale );

		sourceScale = Math.Max( 1.0f, sourceScale );

		_sourceImageRect = new Rect(
			padding,
			top + 28.0f,
			texW * sourceScale,
			texH * sourceScale
		);

		float tileDataTop = _sourceImageRect.Bottom + 24.0f;

		_tileDataRect = new Rect(
			padding,
			tileDataTop,
			leftWidth,
			dataPanelHeight
		);

		UpdateTileDataWidgetGeometry();

		if ( !HasTemplate )
		{
			_templateRect = default;
			return;
		}

		float templateX = padding + leftWidth + gap;
		float templateY = top + 28.0f;
		float aspectH = rightWidth * TemplateRows / Math.Max( 1.0f, TemplateColumns );
		float maxTemplateH = Math.Max( 1.0f, LocalRect.Height - templateY - bottomReserved );

		_templateRect = new Rect(
			templateX,
			templateY,
			rightWidth,
			Math.Min( aspectH, maxTemplateH )
		);
	}

	private void UpdateTileDataWidgetGeometry()
	{
		if ( _tileDataWidget == null )
			return;

		_tileDataWidget.Position = new Vector2( _tileDataRect.Left, _tileDataRect.Top );
		_tileDataWidget.FixedWidth = Math.Max( 1.0f, _tileDataRect.Width );
		_tileDataWidget.FixedHeight = Math.Max( 1.0f, _tileDataRect.Height );
	}

	private void DrawTitles()
	{
		Paint.SetPen( Color.White );

		Paint.DrawText(
			new Rect( _sourceImageRect.Left, _sourceImageRect.Top - 24.0f, _sourceImageRect.Width, 20.0f ),
			"Source Image - drag empty space to define rects, drag existing rects to template",
			TextFlag.LeftCenter
		);

		if ( !HasTemplate )
			return;

		Paint.DrawText(
			new Rect( _templateRect.Left, _templateRect.Top - 24.0f, _templateRect.Width, 20.0f ),
			_template?.Title ?? "Order Template",
			TextFlag.LeftCenter
		);
	}

	private void DrawStatusText()
	{
		if ( string.IsNullOrWhiteSpace( _statusText ) )
			return;

		Rect rect = new Rect(
			8.0f,
			Math.Max( 0.0f, LocalRect.Height - 30.0f ),
			Math.Max( 1.0f, LocalRect.Width - 16.0f ),
			22.0f
		);

		Paint.ClearPen();
		Paint.SetBrush( Color.Black.WithAlpha( 0.35f ) );
		Paint.DrawRect( rect, 3.0f );

		Paint.SetPen( Color.White.WithAlpha( 0.90f ) );
		Paint.DrawText( new Rect( rect.Left + 6.0f, rect.Top, rect.Width - 12.0f, rect.Height ), _statusText, TextFlag.LeftCenter );
	}

	private void DrawSourceImage()
	{
		if ( _sourcePixmap == null )
		{
			Paint.SetPen( Color.Red );
			Paint.DrawText( _sourceImageRect, "No image loaded", TextFlag.Center );
			return;
		}

		Paint.SetBrush( Color.Black );
		Paint.ClearPen();
		Paint.DrawRect( GrowRect( _sourceImageRect, 1.0f ) );

		Paint.Draw( _sourceImageRect, _sourcePixmap );

		DrawSourceGrid();
		DrawExistingRectsOnSource();
		DrawPendingRect();
	}

	private void DrawSourceGrid()
	{
		if ( _tileset == null )
			return;

		if ( _tileset.TileSize.x <= 0 || _tileset.TileSize.y <= 0 )
			return;

		int stepX = Math.Max( 1, _tileset.TileSize.x + Math.Max( 0, _tileset.TileSeparation.x ) );
		int stepY = Math.Max( 1, _tileset.TileSize.y + Math.Max( 0, _tileset.TileSeparation.y ) );

		Paint.SetPen( Color.Cyan.WithAlpha( 0.45f ), 1.0f );

		for ( int x = 0; x <= _sourceTextureSize.x; x += stepX )
		{
			Vector2 a = SourcePixelToScreen( new Vector2( x, 0 ) );
			Vector2 b = SourcePixelToScreen( new Vector2( x, _sourceTextureSize.y ) );
			Paint.DrawLine( a, b );
		}

		for ( int y = 0; y <= _sourceTextureSize.y; y += stepY )
		{
			Vector2 a = SourcePixelToScreen( new Vector2( 0, y ) );
			Vector2 b = SourcePixelToScreen( new Vector2( _sourceTextureSize.x, y ) );
			Paint.DrawLine( a, b );
		}
	}

	private void DrawExistingRectsOnSource()
	{
		if ( _tileset?.Tiles == null )
			return;

		for ( int i = 0; i < _tileset.Tiles.Count; i++ )
		{
			var tile = _tileset.Tiles[i];

			if ( tile == null )
				continue;

			Rect rect = SourcePixelRectToScreen( tile.SourceRect );

			bool selected = i == _selectedTileIndex;
			bool assigned = HasTemplate && IsTileAssignedToTemplate( tile.Id );

			Color border;

			if ( selected )
				border = Color.Yellow;
			else if ( assigned )
				border = Color.Green;
			else
				border = Color.Cyan;

			Paint.ClearBrush();
			Paint.SetPen( border, selected ? 3.0f : 1.0f );
			Paint.DrawRect( rect );

			DrawSmallTextTopLeft( rect, i.ToString(), Color.Red );

			if ( assigned )
			{
				DrawSmallTextBottomCenter( rect, GetAssignedSlotText( tile.Id ), Color.Yellow );
			}
		}
	}

	private void DrawPendingRect()
	{
		Rect rect = _draggingNewRect ? GetCurrentDraggedSourceRect() : _pendingSourceRect;

		if ( rect.Width <= 0 || rect.Height <= 0 )
			return;

		Rect screenRect = SourcePixelRectToScreen( rect );

		Paint.ClearBrush();
		Paint.SetPen( Color.Green, 2.0f );
		Paint.DrawRect( screenRect );
	}

	private void DrawTemplate()
	{
		if ( !HasTemplate )
			return;

		Paint.SetBrush( Color.Black.WithAlpha( 0.35f ) );
		Paint.ClearPen();
		Paint.DrawRect( _templateRect );

		for ( int i = 0; i < SlotCount; i++ )
		{
			Rect slot = GetTemplateSlotRect( i );

			bool hovering = slot.IsInside( _mouseLocalPosition );

			Guid assignedId = _templateSlotIds[i];
			TileDefinition assignedTile = GetTileById( assignedId );
			int assignedTileIndex = GetTileIndexById( assignedId );

			bool assigned = assignedTile != null;

			Color fill = assigned
				? Color.Black.WithAlpha( 0.35f )
				: Color.Black.WithAlpha( 0.25f );

			if ( hovering && _draggingExistingTile )
				fill = Color.Yellow.WithAlpha( 0.22f );

			Color border = assigned
				? Color.Yellow
				: Color.White.WithAlpha( 0.6f );

			Paint.SetBrush( fill );
			Paint.SetPen( border, assigned ? 2.0f : 1.0f );
			Paint.DrawRect( slot );

			DrawTemplateSlotPlaceholder( i, slot );

			if ( assigned )
			{
				DrawAssignedTileSpriteOverTemplateSlot( assignedTile, slot );
			}

			string label = _template.GetLabel( i );
			string tileText = assigned ? $"Tile {assignedTileIndex}" : "empty";

			DrawSmallTextTopLeft( slot, i.ToString(), Color.Red );

			if ( assigned )
			{
				DrawSmallTextTopRight( slot, label, new Color( 0.2f, 0.8f, 0.2f ) );
			}
			else
			{
				DrawCenteredText( slot, label, Color.White.WithAlpha( 0.9f ) );
			}

			DrawSmallTextBottomCenter(
				slot,
				tileText,
				assigned ? Color.Yellow : Color.White.WithAlpha( 0.55f )
			);
		}
	}

	private void DrawTemplateSlotPlaceholder( int slotIndex, Rect slot )
	{
		if ( slotIndex < 0 || slotIndex >= _templateSlotPixmaps.Length )
			return;

		var pixmap = _templateSlotPixmaps[slotIndex];

		if ( pixmap == null )
			return;

		DrawPixmapInsideRect( pixmap, slot, 8.0f, 0.34f );
	}

	private void DrawAssignedTileSpriteOverTemplateSlot( TileDefinition tile, Rect slot )
	{
		var pixmap = GetTileSpritePixmap( tile );

		if ( pixmap == null )
			return;

		Rect spriteBox = slot.Shrink( 14.0f );

		Paint.SetBrush( Color.Black.WithAlpha( 0.70f ) );
		Paint.SetPen( Color.Yellow.WithAlpha( 0.85f ), 1.0f );
		Paint.DrawRect( spriteBox );

		DrawPixmapInsideRect( pixmap, spriteBox, 4.0f, 0.5f );
	}

	private void DrawPixmapInsideRect( Pixmap pixmap, Rect rect, float padding, float verticalAnchor )
	{
		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 )
		);

		if ( scale > 1.0f )
			scale = MathF.Floor( scale );

		scale = Math.Max( 1.0f, 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) * verticalAnchor,
			w,
			h
		);

		Paint.Draw( drawRect, pixmap );
	}

	private Pixmap GetTileSpritePixmap( TileDefinition tile )
	{
		if ( tile == null )
			return null;

		if ( tile.Id == Guid.Empty )
			return null;

		if ( _sourcePixmap == null )
			return null;

		if ( _tileSpritePixmapCache.TryGetValue( tile.Id, out var cached ) && cached != null )
			return cached;

		Rect sourceRect = tile.SourceRect;

		int x = Math.Clamp( (int)MathF.Floor( sourceRect.Left ), 0, _sourcePixmap.Width );
		int y = Math.Clamp( (int)MathF.Floor( sourceRect.Top ), 0, _sourcePixmap.Height );
		int right = Math.Clamp( (int)MathF.Ceiling( sourceRect.Right ), 0, _sourcePixmap.Width );
		int bottom = Math.Clamp( (int)MathF.Ceiling( sourceRect.Bottom ), 0, _sourcePixmap.Height );

		int w = Math.Max( 1, right - x );
		int h = Math.Max( 1, bottom - y );

		var tilePixmap = new Pixmap( w, h );
		tilePixmap.Clear( Color.Transparent );

		using ( Paint.ToPixmap( tilePixmap ) )
		{
			Paint.ClearPen();
			Paint.SetBrush( Color.Transparent );
			Paint.DrawRect( new Rect( 0, 0, w, h ) );

			// Crop the source image by drawing it offset into the small tile pixmap.
			Paint.Draw(
				new Rect(
					-x,
					-y,
					_sourcePixmap.Width,
					_sourcePixmap.Height
				),
				_sourcePixmap
			);
		}

		_tileSpritePixmapCache[tile.Id] = tilePixmap;
		return tilePixmap;
	}

	private void DrawDraggedTileOverlay()
	{
		if ( !_draggingExistingTile || _draggedTileIndex < 0 )
			return;

		var tile = GetTileByIndex( _draggedTileIndex );

		Rect rect = new Rect(
			_dragMousePosition.x - 30.0f,
			_dragMousePosition.y - 30.0f,
			60.0f,
			60.0f
		);

		Paint.SetBrush( Color.Black.WithAlpha( 0.75f ) );
		Paint.SetPen( Color.Yellow, 2.0f );
		Paint.DrawRect( rect );

		if ( tile != null )
		{
			var pixmap = GetTileSpritePixmap( tile );

			if ( pixmap != null )
				DrawPixmapInsideRect( pixmap, rect, 6.0f, 0.5f );
		}

		Paint.SetPen( Color.White );
		Paint.DrawText(
			new Rect( rect.Left, rect.Bottom - 16.0f, rect.Width, 14.0f ),
			_draggedTileIndex.ToString(),
			TextFlag.Center
		);
	}

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

		_mouseLocalPosition = e.LocalPosition;

		if ( !e.LeftMouseButton )
			return;

		Vector2 pos = e.LocalPosition;

		if ( _sourceImageRect.IsInside( pos ) )
		{
			Vector2 pixel = ScreenToSourcePixel( pos );
			int clickedTileIndex = GetTileIndexAtSourcePixel( pixel );

			if ( clickedTileIndex >= 0 )
			{
				_selectedTileIndex = clickedTileIndex;
				_tileDataWidget?.SetSelectedTileIndex( _selectedTileIndex );

				_draggingExistingTile = true;
				_draggedTileIndex = clickedTileIndex;
				_dragMousePosition = pos;

				UpdateStatus();
				Update();
				return;
			}

			_draggingNewRect = true;
			_rectDragStart = SnapSourcePixel( pixel );
			_rectDragEnd = _rectDragStart;
			_pendingSourceRect = default;

			UpdateStatus();
			Update();
			return;
		}
	}

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

		_mouseLocalPosition = e.LocalPosition;

		Vector2 pos = e.LocalPosition;

		if ( _draggingNewRect )
		{
			_rectDragEnd = SnapSourcePixel( ScreenToSourcePixel( pos ) );
			Update();
			return;
		}

		if ( _draggingExistingTile )
		{
			_dragMousePosition = pos;
			Update();
			return;
		}

		Update();
	}

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

		_mouseLocalPosition = e.LocalPosition;

		Vector2 pos = e.LocalPosition;

		if ( _draggingNewRect )
		{
			_draggingNewRect = false;
			_rectDragEnd = SnapSourcePixel( ScreenToSourcePixel( pos ) );
			_pendingSourceRect = GetCurrentDraggedSourceRect();

			UpdateStatus();
			Update();
			return;
		}

		if ( _draggingExistingTile )
		{
			_draggingExistingTile = false;

			if ( HasTemplate )
			{
				int slot = GetTemplateSlotAtPosition( pos );

				if ( slot >= 0 && _draggedTileIndex >= 0 )
				{
					AssignTileToTemplateSlot( _draggedTileIndex, slot );
				}
			}

			_draggedTileIndex = -1;

			UpdateStatus();
			Update();
			return;
		}
	}

	public void AddPendingRect()
	{
		if ( _tileset == null )
			return;

		if ( _pendingSourceRect.Width <= 0 || _pendingSourceRect.Height <= 0 )
			return;

		_tileset.Tiles ??= new();

		var tile = new TileDefinition
		{
			Name = $"Tile {_tileset.Tiles.Count}",
			SourceRect = _pendingSourceRect
		};

		_tileset.AddTile( tile );

		_selectedTileIndex = _tileset.Tiles.Count - 1;
		_tileDataWidget?.SetSelectedTileIndex( _selectedTileIndex );
		_pendingSourceRect = default;

		_tileSpritePixmapCache.Clear();

		_onChanged?.Invoke();

		UpdateStatus();
		Update();
	}

	public void DeleteSelectedTile()
	{
		if ( _tileset == null || _tileset.Tiles == null )
			return;

		if ( _selectedTileIndex < 0 || _selectedTileIndex >= _tileset.Tiles.Count )
			return;

		Guid removedId = _tileset.Tiles[_selectedTileIndex].Id;

		for ( int i = 0; i < _templateSlotIds.Length; i++ )
		{
			if ( _templateSlotIds[i] == removedId )
				_templateSlotIds[i] = Guid.Empty;
		}

		_tileset.RemoveTileAt( _selectedTileIndex );

		_selectedTileIndex = -1;
		_tileDataWidget?.SetSelectedTileIndex( _selectedTileIndex );

		_tileSpritePixmapCache.Remove( removedId );

		_onChanged?.Invoke();

		UpdateStatus();
		Update();
	}

	private void AssignTileToTemplateSlot( int tileIndex, int slot )
	{
		if ( !HasTemplate )
			return;

		if ( _tileset?.Tiles == null )
			return;

		if ( tileIndex < 0 || tileIndex >= _tileset.Tiles.Count )
			return;

		if ( slot < 0 || slot >= _templateSlotIds.Length )
			return;

		var tile = _tileset.Tiles[tileIndex];

		if ( tile == null )
			return;

		if ( tile.Id == Guid.Empty )
			tile.Id = Guid.NewGuid();

		Guid tileId = tile.Id;

		// Remove from any old slot first, so the same rect cannot appear twice.
		for ( int i = 0; i < _templateSlotIds.Length; i++ )
		{
			if ( _templateSlotIds[i] == tileId )
				_templateSlotIds[i] = Guid.Empty;
		}

		_templateSlotIds[slot] = tileId;
		_selectedTileIndex = tileIndex;
		_tileDataWidget?.SetSelectedTileIndex( _selectedTileIndex );
	}

	public void ApplyTemplateOrder()
	{
		if ( !HasTemplate )
			return;

		if ( _tileset?.Tiles == null )
			return;

		var ordered = new List<TileDefinition>();

		for ( int i = 0; i < SlotCount; i++ )
		{
			var tile = GetTileById( _templateSlotIds[i] );

			if ( tile == null )
				continue;

			if ( ordered.Contains( tile ) )
				continue;

			ordered.Add( tile );
		}

		if ( ordered.Count == 0 )
			return;

		_tileset.ReorderTiles( ordered );

		LoadTemplateFromCurrentOrder();

		_selectedTileIndex = -1;
		_tileDataWidget?.SetSelectedTileIndex( _selectedTileIndex );
		_tileSpritePixmapCache.Clear();

		_onChanged?.Invoke();

		UpdateStatus();
		Update();
	}

	private Rect GetCurrentDraggedSourceRect()
	{
		float left = Math.Min( _rectDragStart.x, _rectDragEnd.x );
		float top = Math.Min( _rectDragStart.y, _rectDragEnd.y );
		float right = Math.Max( _rectDragStart.x, _rectDragEnd.x );
		float bottom = Math.Max( _rectDragStart.y, _rectDragEnd.y );

		left = Math.Clamp( left, 0, _sourceTextureSize.x );
		right = Math.Clamp( right, 0, _sourceTextureSize.x );
		top = Math.Clamp( top, 0, _sourceTextureSize.y );
		bottom = Math.Clamp( bottom, 0, _sourceTextureSize.y );

		return new Rect( left, top, right - left, bottom - top );
	}

	private int GetTileIndexAtSourcePixel( Vector2 pixel )
	{
		if ( _tileset?.Tiles == null )
			return -1;

		for ( int i = _tileset.Tiles.Count - 1; i >= 0; i-- )
		{
			var tile = _tileset.Tiles[i];

			if ( tile == null )
				continue;

			if ( RectContains( tile.SourceRect, pixel ) )
				return i;
		}

		return -1;
	}

	private int GetTemplateSlotAtPosition( Vector2 pos )
	{
		if ( !HasTemplate )
			return -1;

		for ( int i = 0; i < SlotCount; i++ )
		{
			if ( GetTemplateSlotRect( i ).IsInside( pos ) )
				return i;
		}

		return -1;
	}

	private Rect GetTemplateSlotRect( int index )
	{
		int col = index % TemplateColumns;
		int row = index / TemplateColumns;

		float gap = 6.0f;
		float slotW = (_templateRect.Width - gap * (TemplateColumns + 1)) / TemplateColumns;
		float slotH = (_templateRect.Height - gap * (TemplateRows + 1)) / TemplateRows;

		return new Rect(
			_templateRect.Left + gap + col * (slotW + gap),
			_templateRect.Top + gap + row * (slotH + gap),
			slotW,
			slotH
		);
	}

	private Vector2 ScreenToSourcePixel( Vector2 localPosition )
	{
		float u = (localPosition.x - _sourceImageRect.Left) / Math.Max( 1.0f, _sourceImageRect.Width );
		float v = (localPosition.y - _sourceImageRect.Top) / Math.Max( 1.0f, _sourceImageRect.Height );

		u = Math.Clamp( u, 0.0f, 1.0f );
		v = Math.Clamp( v, 0.0f, 1.0f );

		return new Vector2(
			u * _sourceTextureSize.x,
			v * _sourceTextureSize.y
		);
	}

	private Vector2 SourcePixelToScreen( Vector2 pixel )
	{
		float u = pixel.x / Math.Max( 1.0f, _sourceTextureSize.x );
		float v = pixel.y / Math.Max( 1.0f, _sourceTextureSize.y );

		return new Vector2(
			_sourceImageRect.Left + u * _sourceImageRect.Width,
			_sourceImageRect.Top + v * _sourceImageRect.Height
		);
	}

	private Rect SourcePixelRectToScreen( Rect pixelRect )
	{
		Vector2 a = SourcePixelToScreen( new Vector2( pixelRect.Left, pixelRect.Top ) );
		Vector2 b = SourcePixelToScreen( new Vector2( pixelRect.Right, pixelRect.Bottom ) );

		return new Rect(
			a.x,
			a.y,
			b.x - a.x,
			b.y - a.y
		);
	}

	private Vector2 SnapSourcePixel( Vector2 pixel )
	{
		if ( _tileset == null )
			return pixel;

		pixel.x = Math.Clamp( pixel.x, 0, _sourceTextureSize.x );
		pixel.y = Math.Clamp( pixel.y, 0, _sourceTextureSize.y );

		int tileW = Math.Max( 1, _tileset.TileSize.x );
		int tileH = Math.Max( 1, _tileset.TileSize.y );

		pixel.x = MathF.Round( pixel.x / tileW ) * tileW;
		pixel.y = MathF.Round( pixel.y / tileH ) * tileH;

		return pixel;
	}

	private bool RectContains( Rect rect, Vector2 point )
	{
		return point.x >= rect.Left
			&& point.x < rect.Right
			&& point.y >= rect.Top
			&& point.y < rect.Bottom;
	}

	private bool IsTileAssignedToTemplate( Guid id )
	{
		if ( id == Guid.Empty )
			return false;

		for ( int i = 0; i < _templateSlotIds.Length; i++ )
		{
			if ( _templateSlotIds[i] == id )
				return true;
		}

		return false;
	}

	private string GetAssignedSlotText( Guid id )
	{
		for ( int i = 0; i < _templateSlotIds.Length; i++ )
		{
			if ( _templateSlotIds[i] == id )
				return $"Slot {i}";
		}

		return "";
	}

	private TileDefinition GetTileById( Guid id )
	{
		if ( id == Guid.Empty || _tileset?.Tiles == null )
			return null;

		foreach ( var tile in _tileset.Tiles )
		{
			if ( tile == null )
				continue;

			if ( tile.Id == id )
				return tile;
		}

		return null;
	}

	private TileDefinition GetTileByIndex( int index )
	{
		if ( _tileset?.Tiles == null )
			return null;

		if ( index < 0 || index >= _tileset.Tiles.Count )
			return null;

		return _tileset.Tiles[index];
	}

	private int GetTileIndexById( Guid id )
	{
		if ( id == Guid.Empty || _tileset?.Tiles == null )
			return -1;

		for ( int i = 0; i < _tileset.Tiles.Count; i++ )
		{
			var tile = _tileset.Tiles[i];

			if ( tile == null )
				continue;

			if ( tile.Id == id )
				return i;
		}

		return -1;
	}

	private void RemoveInvalidTemplateAssignments()
	{
		for ( int i = 0; i < _templateSlotIds.Length; i++ )
		{
			if ( _templateSlotIds[i] == Guid.Empty )
				continue;

			if ( GetTileById( _templateSlotIds[i] ) == null )
				_templateSlotIds[i] = Guid.Empty;
		}
	}

	private Rect GrowRect( Rect rect, float amount )
	{
		return new Rect(
			rect.Left - amount,
			rect.Top - amount,
			rect.Width + amount * 2.0f,
			rect.Height + amount * 2.0f
		);
	}

	private void DrawSmallTextTopLeft( Rect rect, string text, Color color )
	{
		Paint.SetPen( color );
		Paint.DrawText(
			new Rect( rect.Left + 4.0f, rect.Top + 2.0f, rect.Width - 8.0f, 16.0f ),
			text,
			TextFlag.LeftCenter
		);
	}

	private void DrawSmallTextTopRight( Rect rect, string text, Color color )
	{
		Paint.SetPen( color );
		Paint.DrawText(
			new Rect( rect.Left + 4.0f, rect.Top + 2.0f, rect.Width - 8.0f, 16.0f ),
			text,
			TextFlag.RightCenter
		);
	}

	private void DrawSmallTextBottomCenter( Rect rect, string text, Color color )
	{
		Paint.SetPen( color );
		Paint.DrawText(
			new Rect( rect.Left + 4.0f, rect.Bottom - 18.0f, rect.Width - 8.0f, 16.0f ),
			text,
			TextFlag.Center
		);
	}

	private void DrawCenteredText( Rect rect, string text, Color color )
	{
		Paint.SetPen( color );
		Paint.DrawText(
			new Rect( rect.Left + 4.0f, rect.Top + 18.0f, rect.Width - 8.0f, rect.Height - 36.0f ),
			text,
			TextFlag.Center
		);
	}
}