Editor/HammerTextureBrowserDock.cs

Editor dock widget for a texture browser used in the Hammer editor. It lists material assets, shows thumbnails, filters by name/keyword/usage, previews texture sizes, and exposes actions to mark/replace/open materials and interact with the Hammer map via reflection.

ReflectionFile Access
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using Editor;
using Sandbox;

namespace HammerTextureBrowser;

[Dock( "Editor", "OG Texture Browser", "texture" )]
public sealed class HammerTextureBrowserDock : Widget
{
	private static readonly Regex QuotedMaterialProperty = new( "\"(?<key>[^\"]+)\"\\s+\"(?<value>[^\"]+)\"", RegexOptions.Compiled | RegexOptions.CultureInvariant );
	private static readonly string[] PrimaryTextureKeys =
	[
		"TextureColor",
		"TextureBaseColor",
		"TextureAlbedo",
		"AlbedoTexture",
		"BaseTexture",
		"BaseColorTexture",
		"g_tColor",
		"g_tBaseColor",
		"g_tAlbedo"
	];

	private readonly HammerTextureList TextureList;
	private readonly ComboBox SizeCombo;
	private readonly LineEdit FilterEdit;
	private readonly ComboBox KeywordsCombo;
	private readonly Checkbox OnlyUsedTextures;
	private readonly Label SelectedTextureLabel;
	private readonly Label TextureSizeLabel;
	private readonly Label CountLabel;
	private readonly Button MarkButton;
	private readonly Button ReplaceButton;
	private readonly Button ReloadButton;
	private readonly Button OpenSourceButton;

	private List<Asset> AllMaterials = new();
	private Asset SelectedMaterial;
	private string KeywordFilter = string.Empty;
	private int PreviewSize = 128;

	public HammerTextureBrowserDock( Widget parent ) : base( parent )
	{
		MinimumSize = new( 420, 320 );
		WindowTitle = "OG Texture Browser";
		SetWindowIcon( "texture" );

		Layout = Layout.Column();
		Layout.Margin = 0;
		Layout.Spacing = 0;

		TextureList = Layout.Add( new HammerTextureList( this ), 1 );
		TextureList.OnTextureSelected = SelectMaterial;
		TextureList.OnTextureActivated = SelectMaterial;
		TextureList.OnOpenInEditor = OpenMaterialSource;
		TextureList.OnOpenInAssetBrowser = OpenInAssetBrowser;

		var bottom = Layout.Add( new Widget( this ) );
		bottom.Layout = Layout.Column();
		bottom.Layout.Margin = 4;
		bottom.Layout.Spacing = 3;
		bottom.FixedHeight = Theme.RowHeight * 2 + 12;
		bottom.OnPaintOverride = () =>
		{
			Paint.ClearPen();
			Paint.SetBrush( Theme.ControlBackground );
			Paint.DrawRect( bottom.LocalRect );
			return false;
		};

		var topRow = bottom.Layout.AddRow();
		topRow.Spacing = 5;

		var sizeBlock = topRow.Add( new Widget( this ) );
		sizeBlock.FixedWidth = 138;
		sizeBlock.MinimumWidth = 138;
		sizeBlock.MaximumWidth = 138;
		sizeBlock.Layout = Layout.Row();
		sizeBlock.Layout.Margin = 0;
		sizeBlock.Layout.Spacing = 5;

		AddSmallLabel( sizeBlock.Layout, "Size:" );
		SizeCombo = sizeBlock.Layout.Add( new ComboBox( sizeBlock ) );
		SizeCombo.FixedWidth = 88;
		SizeCombo.MinimumWidth = 88;
		SizeCombo.MaximumWidth = 88;
		SizeCombo.AddItem( "32x32", null, () => SetPreviewSize( 32 ) );
		SizeCombo.AddItem( "64x64", null, () => SetPreviewSize( 64 ) );
		SizeCombo.AddItem( "128x128", null, () => SetPreviewSize( 128 ) );
		SizeCombo.AddItem( "256x256", null, () => SetPreviewSize( 256 ) );
		SizeCombo.AddItem( "1:1", null, () => SetPreviewSize( 128 ) );
		SizeCombo.CurrentIndex = 2;
		StyleBottomInput( SizeCombo, 24 );

		AddSmallLabel( topRow, "Filter:", 58 );
		FilterEdit = topRow.Add( new LineEdit( this ) );
		FilterEdit.FixedWidth = 176;
		FilterEdit.MinimumWidth = 176;
		FilterEdit.MaximumWidth = 176;
		FilterEdit.PlaceholderText = "texture name";
		FilterEdit.TextChanged += _ => RefreshList();
		StyleBottomInput( FilterEdit );

		SelectedTextureLabel = topRow.Add( new Label( this ) );
		SelectedTextureLabel.MinimumWidth = 180;
		SelectedTextureLabel.Alignment = TextFlag.LeftCenter;
		SelectedTextureLabel.Text = "";

		OpenSourceButton = topRow.Add( new Button( "Open Source" ) );
		OpenSourceButton.FixedWidth = 96;
		OpenSourceButton.Clicked = OpenSelectedSource;

		var bottomRow = bottom.Layout.AddRow();
		bottomRow.Spacing = 5;
		OnlyUsedTextures = bottomRow.Add( new Checkbox( "Only used textures" ) );
		OnlyUsedTextures.FixedWidth = 138;
		OnlyUsedTextures.StateChanged += _ => RefreshList();

		AddSmallLabel( bottomRow, "Keywords:", 58 );
		KeywordsCombo = bottomRow.Add( new ComboBox( this ) );
		KeywordsCombo.FixedWidth = 176;
		KeywordsCombo.MinimumWidth = 176;
		KeywordsCombo.MaximumWidth = 176;
		KeywordsCombo.AddItem( "All Keywords", null, () => SetKeywordFilter( string.Empty ) );
		StyleBottomInput( KeywordsCombo, 24 );

		MarkButton = bottomRow.Add( new Button( "Mark" ) );
		MarkButton.FixedWidth = 76;
		MarkButton.Clicked = MarkSelectedMaterial;

		ReplaceButton = bottomRow.Add( new Button( "Replace" ) );
		ReplaceButton.FixedWidth = 76;
		ReplaceButton.Clicked = ReplaceSelectedMaterial;

		ReloadButton = bottomRow.Add( new Button( "Reload" ) );
		ReloadButton.FixedWidth = 76;
		ReloadButton.Clicked = Reload;

		TextureSizeLabel = bottomRow.Add( new Label( this ) );
		TextureSizeLabel.MinimumWidth = 0;
		TextureSizeLabel.MaximumWidth = 68;
		TextureSizeLabel.Alignment = TextFlag.RightCenter;

		bottomRow.AddStretchCell( 1 );

		CountLabel = bottomRow.Add( new Label( this ) );
		CountLabel.MinimumWidth = 0;
		CountLabel.MaximumWidth = 96;
		CountLabel.Alignment = TextFlag.RightCenter;

		ReloadMaterials();
		SetPreviewSize( PreviewSize );
		RefreshList();
		UpdateSelectedMaterialUi();
	}

	private void Reload()
	{
		ReloadMaterials();
		RefreshList();
		UpdateSelectedMaterialUi();
	}

	private static void AddSmallLabel( Layout row, string text, int fixedWidth = 0 )
	{
		var label = row.Add( new Label( text ) );
		label.Alignment = fixedWidth > 0 ? TextFlag.RightCenter : TextFlag.LeftCenter;
		label.FixedWidth = fixedWidth > 0 ? fixedWidth : text.Length * 6 + 4;
	}

	private static void StyleBottomInput( Widget widget, int fixedHeight = 22 )
	{
		widget.FixedHeight = fixedHeight;
		widget.SetStyles(
			"background-color: #2d3136; " +
			"border: 1px solid #555c64; " +
			"border-radius: 2px; " +
			"color: #f2f2f2; " +
			"padding-top: 0px; " +
			"padding-bottom: 0px; " +
			"padding-left: 5px; " +
			"padding-right: 5px;" );
	}

	private void ReloadMaterials()
	{
		var menuPath = EditorUtility.Projects.GetAll()
			.FirstOrDefault( x => x.Config.Ident == "menu" )
			?.GetAssetsPath()
			.NormalizeFilename( false );

		AllMaterials = AssetSystem.All
			.Where( IsBrowsableMaterial )
			.Where( x => !IsMenuAsset( x, menuPath ) )
			.OrderBy( TextureDisplayName, StringComparer.OrdinalIgnoreCase )
			.ToList();

		RebuildKeywords();
	}

	private static bool IsBrowsableMaterial( Asset asset )
	{
		if ( asset is null )
			return false;

		if ( asset.AssetType != AssetType.Material )
			return false;

		if ( asset.AbsolutePath?.Contains( ".sbox/cloud/", StringComparison.OrdinalIgnoreCase ) ?? false )
			return false;

		return true;
	}

	private static bool IsMenuAsset( Asset asset, string menuPath )
	{
		if ( string.IsNullOrEmpty( menuPath ) )
			return false;

		return asset.AbsolutePath?.NormalizeFilename( false ).StartsWith( menuPath, StringComparison.OrdinalIgnoreCase ) ?? false;
	}

	private void RebuildKeywords()
	{
		var tags = AllMaterials
			.SelectMany( x => x.Tags )
			.Where( x => !string.IsNullOrWhiteSpace( x ) )
			.Distinct( StringComparer.OrdinalIgnoreCase )
			.OrderBy( x => x, StringComparer.OrdinalIgnoreCase )
			.ToList();

		KeywordsCombo.Clear();
		KeywordsCombo.AddItem( "All Keywords", null, () => SetKeywordFilter( string.Empty ) );

		foreach ( var tag in tags )
		{
			var tagValue = tag;
			KeywordsCombo.AddItem( tagValue, null, () => SetKeywordFilter( tagValue ) );
		}

		KeywordsCombo.CurrentIndex = 0;
	}

	private void SetKeywordFilter( string tag )
	{
		KeywordFilter = tag ?? string.Empty;
		RefreshList();
	}

	private void SetPreviewSize( int size )
	{
		PreviewSize = size;
		TextureList.SetPreviewSize( PreviewSize );
		RefreshList();
	}

	private void RefreshList()
	{
		if ( TextureList is null )
			return;

		IEnumerable<Asset> materials = AllMaterials;

		var query = FilterEdit?.Text ?? string.Empty;
		if ( !string.IsNullOrWhiteSpace( query ) )
		{
			var parts = query.Split( ' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries );
			materials = materials.Where( x => MatchesQuery( x, parts ) );
		}

		if ( !string.IsNullOrWhiteSpace( KeywordFilter ) )
		{
			materials = materials.Where( x => x.Tags.Any( tag => tag.Equals( KeywordFilter, StringComparison.OrdinalIgnoreCase ) ) );
		}

		if ( OnlyUsedTextures?.Value ?? false )
		{
			var used = HammerMaterialSelection.GetUsedMaterialKeys();
			materials = materials.Where( x => used.Contains( x.RelativePath ) || used.Contains( x.AbsolutePath ) || used.Contains( TextureDisplayName( x ) ) );
		}

		var entries = materials
			.OrderBy( TextureDisplayName, StringComparer.OrdinalIgnoreCase )
			.Select( x => new HammerTextureEntry( x ) )
			.ToList();

		TextureList.SetItems( entries );
		if ( CountLabel is not null )
		{
			CountLabel.Text = $"{entries.Count:n0} textures";
			CountLabel.Update();
		}

		if ( SelectedMaterial is not null && entries.All( x => x.Asset != SelectedMaterial ) )
			TextureList.UnselectAll();
	}

	private static bool MatchesQuery( Asset asset, IEnumerable<string> parts )
	{
		var name = TextureDisplayName( asset );
		var type = asset.AssetType?.FriendlyName ?? string.Empty;
		IEnumerable<string> tags = asset.Tags;

		foreach ( var part in parts )
		{
			var search = part;
			var negated = false;
			if ( search.StartsWith( "-" ) )
			{
				search = search[1..];
				negated = true;
			}

			var matched =
				name.Contains( search, StringComparison.OrdinalIgnoreCase ) ||
				type.Contains( search, StringComparison.OrdinalIgnoreCase ) ||
				tags.Any( x => x.Contains( search, StringComparison.OrdinalIgnoreCase ) );

			if ( !negated && !matched )
				return false;

			if ( negated && matched )
				return false;
		}

		return true;
	}

	private void SelectMaterial( Asset material )
	{
		if ( material?.AssetType != AssetType.Material )
			return;

		SelectedMaterial = material;
		EditorUtility.InspectorObject = material;
		HammerMaterialSelection.SetCurrentMaterial( material );
		UpdateSelectedMaterialUi();
	}

	private void UpdateSelectedMaterialUi()
	{
		var hasMaterial = SelectedMaterial is not null;

		SelectedTextureLabel.Text = hasMaterial ? TextureDisplayName( SelectedMaterial ) : "";
		SelectedTextureLabel.ToolTip = SelectedMaterial?.RelativePath ?? string.Empty;

		TextureSizeLabel.Text = GetTextureSizeText( SelectedMaterial );
		TextureSizeLabel.ToolTip = SelectedMaterial?.AbsolutePath ?? string.Empty;

		MarkButton.Enabled = hasMaterial;
		ReplaceButton.Enabled = hasMaterial;
		OpenSourceButton.Enabled = hasMaterial;

		SelectedTextureLabel.Update();
		TextureSizeLabel.Update();
		MarkButton.Update();
		ReplaceButton.Update();
		OpenSourceButton.Update();
	}

	private static string GetTextureSizeText( Asset asset )
	{
		if ( asset is null )
			return "";

		var texturePath = GetPrimaryMaterialTexturePath( asset );
		if ( string.IsNullOrWhiteSpace( texturePath ) )
			return "";

		try
		{
			var texture = Texture.Load( texturePath );
			if ( texture is null || !texture.IsValid() || texture.Width <= 0 || texture.Height <= 0 )
				return "";

			return $"{texture.Width}x{texture.Height}";
		}
		catch
		{
			return "";
		}
	}

	private static string GetPrimaryMaterialTexturePath( Asset asset )
	{
		if ( string.IsNullOrWhiteSpace( asset?.AbsolutePath ) || !File.Exists( asset.AbsolutePath ) )
			return null;

		try
		{
			var properties = QuotedMaterialProperty.Matches( File.ReadAllText( asset.AbsolutePath ) )
				.Select( x => (Key: x.Groups["key"].Value, Value: NormalizeTexturePath( x.Groups["value"].Value )) )
				.Where( x => IsTexturePath( x.Value ) )
				.ToList();

			foreach ( var key in PrimaryTextureKeys )
			{
				var match = properties.FirstOrDefault( x => x.Key.Equals( key, StringComparison.OrdinalIgnoreCase ) );
				if ( !string.IsNullOrWhiteSpace( match.Value ) )
					return match.Value;
			}

			var colorMatch = properties.FirstOrDefault( x =>
				(x.Key.Contains( "color", StringComparison.OrdinalIgnoreCase ) ||
				x.Key.Contains( "albedo", StringComparison.OrdinalIgnoreCase ) ||
				x.Key.Contains( "base", StringComparison.OrdinalIgnoreCase )) &&
				!x.Key.Contains( "tint", StringComparison.OrdinalIgnoreCase ) );

			if ( !string.IsNullOrWhiteSpace( colorMatch.Value ) )
				return colorMatch.Value;

			return properties.FirstOrDefault().Value;
		}
		catch
		{
			return null;
		}
	}

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

	private static bool IsTexturePath( string path )
	{
		var extension = Path.GetExtension( path );
		return extension.Equals( ".vtex", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".tga", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".png", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".jpg", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".jpeg", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".tif", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".tiff", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".psd", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".exr", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".hdr", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".bmp", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".webp", StringComparison.OrdinalIgnoreCase );
	}

	private void MarkSelectedMaterial()
	{
		if ( SelectedMaterial is null )
			return;

		HammerMaterialSelection.SelectFacesUsingMaterial( SelectedMaterial );
	}

	private void ReplaceSelectedMaterial()
	{
		if ( SelectedMaterial is null )
			return;

		HammerMaterialSelection.AssignAssetToSelection( SelectedMaterial );
	}

	private void OpenSelectedSource()
	{
		OpenMaterialSource( SelectedMaterial );
	}

	private void OpenMaterialSource( Asset asset )
	{
		if ( asset is null )
			return;

		if ( asset.CanOpenInEditor )
		{
			asset.OpenInEditor();
			return;
		}

		EditorUtility.OpenFileFolder( asset.AbsolutePath );
	}

	private void OpenInAssetBrowser( Asset asset )
	{
		if ( asset is null )
			return;

		LocalAssetBrowser.OpenTo( asset, true );
	}

	internal static string TextureDisplayName( Asset asset )
	{
		var path = asset?.RelativePath ?? asset?.Name ?? "";
		path = path.NormalizeFilename( false, false );

		if ( path.StartsWith( "materials/", StringComparison.OrdinalIgnoreCase ) )
			path = path["materials/".Length..];

		var extension = Path.GetExtension( path );
		if ( !string.IsNullOrEmpty( extension ) )
			path = path[..^extension.Length];

		return path;
	}
}

internal sealed class HammerTextureList : ListView
{
	private int PreviewSize = 128;

	public Action<Asset> OnTextureSelected;
	public Action<Asset> OnTextureActivated;
	public Action<Asset> OnOpenInEditor;
	public Action<Asset> OnOpenInAssetBrowser;

	public HammerTextureList( Widget parent ) : base( parent )
	{
		MultiSelect = false;
		FocusMode = FocusMode.TabOrClick;
		Margin = 2;
		ItemSpacing = 2;
		ItemPaint = PaintTextureItem;
		ItemSelected = item => SelectTexture( item, false );
		ItemActivated = item => SelectTexture( item, true );
		ItemDrag = StartTextureDrag;
		ItemContextMenu = OpenTextureContextMenu;
		ItemScrollEnter = item => (item as HammerTextureEntry)?.OnScrollEnter();
		ItemScrollExit = item => (item as HammerTextureEntry)?.OnScrollExit();
		OnPaintOverride = PaintBackground;
	}

	public void SetPreviewSize( int previewSize )
	{
		PreviewSize = previewSize;
		ItemSize = new Vector2( PreviewSize + 4, PreviewSize + 18 );
		Update();
	}

	public void SetItems( IEnumerable<HammerTextureEntry> entries )
	{
		base.SetItems( entries.Cast<object>() );
		Update();
	}

	private bool PaintBackground()
	{
		Paint.ClearPen();
		Paint.SetBrush( Color.Black );
		Paint.DrawRect( LocalRect );
		return false;
	}

	private void SelectTexture( object item, bool activated )
	{
		if ( item is not HammerTextureEntry entry )
			return;

		if ( activated )
			OnTextureActivated?.Invoke( entry.Asset );
		else
			OnTextureSelected?.Invoke( entry.Asset );
	}

	private bool StartTextureDrag( object item )
	{
		if ( item is not HammerTextureEntry entry || entry.Asset is null )
			return false;

		SelectItem( entry );
		SelectTexture( entry, false );

		var drag = new Drag( this );
		drag.Data.Object = entry.Asset;
		drag.Data.Text = entry.Asset.RelativePath;
		drag.Data.Url = new Uri( "file:///" + entry.Asset.AbsolutePath );

		foreach ( var selected in SelectedItems.OfType<HammerTextureEntry>() )
		{
			if ( selected == entry || selected.Asset is null )
				continue;

			drag.Data.Text += "\n" + selected.Asset.RelativePath;
		}

		drag.Execute();
		return true;
	}

	private void OpenTextureContextMenu( object item )
	{
		if ( item is not HammerTextureEntry entry )
			return;

		SelectItem( entry );
		SelectTexture( entry, false );

		var menu = new ContextMenu( this );
		menu.AddOption( "Open in Editor", "edit", () => OnOpenInEditor?.Invoke( entry.Asset ) )
			.Enabled = entry.Asset is not null;
		menu.AddOption( "Open in Asset Browser", "search", () => OnOpenInAssetBrowser?.Invoke( entry.Asset ) )
			.Enabled = entry.Asset is not null;
		menu.OpenAtCursor( false );
	}

	private void PaintTextureItem( VirtualWidget item )
	{
		if ( item.Object is not HammerTextureEntry entry )
			return;

		var rect = item.Rect.Shrink( 1 );
		var active = Paint.HasPressed;
		var selected = Paint.HasSelected || Paint.HasPressed;
		var hover = !selected && Paint.HasMouseOver;

		Paint.ClearPen();
		Paint.SetBrush( Color.Black );
		Paint.DrawRect( rect );

		if ( selected )
		{
			Paint.SetPen( Theme.Primary, 2 );
			Paint.ClearBrush();
			Paint.DrawRect( rect.Grow( -1 ) );
		}
		else if ( hover )
		{
			Paint.SetPen( Theme.ControlBackground.Lighten( 0.35f ), 1 );
			Paint.ClearBrush();
			Paint.DrawRect( rect.Grow( -1 ) );
		}

		var previewRect = rect;
		previewRect.Width = PreviewSize;
		previewRect.Height = PreviewSize;
		previewRect.Left += MathF.Max( 0, (rect.Width - PreviewSize) * 0.5f );

		entry.DrawPreview( previewRect, active );

		var nameRect = previewRect;
		nameRect.Top = previewRect.Bottom;
		nameRect.Height = 12;

		Paint.ClearPen();
		Paint.SetBrush( selected ? Theme.Primary.Lighten( active ? -0.1f : 0.05f ) : new Color( 0.0f, 0.05f, 0.85f ) );
		Paint.DrawRect( nameRect );

		Paint.SetDefaultFont( 6 );
		Paint.SetPen( Color.White );
		var label = Paint.GetElidedText( entry.DisplayName, nameRect.Width - 4, ElideMode.Middle );
		Paint.DrawText( nameRect.Shrink( 2, 0 ), label, TextFlag.LeftCenter | TextFlag.SingleLine );
	}
}

internal sealed class HammerTextureEntry
{
	public Asset Asset { get; }
	public string DisplayName { get; }

	public HammerTextureEntry( Asset asset )
	{
		Asset = asset;
		DisplayName = HammerTextureBrowserDock.TextureDisplayName( asset );
	}

	public void OnScrollEnter()
	{
		if ( Asset is null || Asset.HasCachedThumbnail )
			return;

		EditorEvent.Register( this );
		Asset.GetAssetThumb( true );
	}

	public void OnScrollExit()
	{
		if ( Asset is null )
			return;

		Asset.CancelThumbBuild();
		EditorEvent.Unregister( this );
	}

	public void DrawPreview( Rect rect, bool active )
	{
		Paint.ClearPen();
		Paint.SetBrush( active ? Color.Black.Lighten( 0.08f ) : Color.Black );
		Paint.DrawRect( rect );

		var thumb = Asset?.GetAssetThumb( true ) ?? AssetType.Material?.Icon128;
		if ( thumb is null )
			return;

		var drawRect = rect;
		var scale = MathF.Min( rect.Width / thumb.Width, rect.Height / thumb.Height );
		if ( scale > 0 && float.IsFinite( scale ) )
		{
			drawRect.Width = MathF.Min( rect.Width, thumb.Width * scale );
			drawRect.Height = MathF.Min( rect.Height, thumb.Height * scale );
			drawRect.Left = rect.Left + (rect.Width - drawRect.Width) * 0.5f;
			drawRect.Top = rect.Top + (rect.Height - drawRect.Height) * 0.5f;
		}

		Paint.BilinearFiltering = true;
		Paint.Draw( drawRect, thumb );
		Paint.BilinearFiltering = false;
	}
}

internal static class HammerMaterialSelection
{
	private const BindingFlags StaticFlags = BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
	private const BindingFlags InstanceFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;

	private static Type HammerType;
	private static bool HasResolvedHammerType;

	public static void SetCurrentMaterial( Asset asset ) => InvokeHammerAssetMethod( "SetCurrentMaterial", asset );
	public static void SelectFacesUsingMaterial( Asset asset ) => InvokeHammerAssetMethod( "SelectFacesUsingMaterial", asset );
	public static void AssignAssetToSelection( Asset asset ) => InvokeHammerAssetMethod( "AssignAssetToSelection", asset );

	public static HashSet<string> GetUsedMaterialKeys()
	{
		var keys = new HashSet<string>( StringComparer.OrdinalIgnoreCase );

		try
		{
			var activeMap = GetHammerType()?.GetProperty( "ActiveMap", StaticFlags )?.GetValue( null );
			if ( activeMap is null )
				return keys;

			var world = activeMap.GetType().GetProperty( "World", InstanceFlags )?.GetValue( activeMap );
			if ( world is null )
				return keys;

			CollectUsedMaterialKeys( world, keys );
		}
		catch
		{
			// Hammer throws when no map is open; in that case "Only used textures" should simply show none.
			return keys;
		}

		return keys;
	}

	private static void CollectUsedMaterialKeys( object node, HashSet<string> keys )
	{
		if ( node is null )
			return;

		var materialMethod = node.GetType().GetMethod( "GetFaceMaterialAssets", InstanceFlags );
		if ( materialMethod is not null && materialMethod.Invoke( node, null ) is IEnumerable materials )
		{
			foreach ( var item in materials )
			{
				if ( item is not Asset asset )
					continue;

				keys.Add( asset.RelativePath );
				keys.Add( asset.AbsolutePath );
				keys.Add( HammerTextureBrowserDock.TextureDisplayName( asset ) );
			}
		}

		var children = node.GetType().GetProperty( "Children", InstanceFlags )?.GetValue( node ) as IEnumerable;
		if ( children is null )
			return;

		foreach ( var child in children )
		{
			CollectUsedMaterialKeys( child, keys );
		}
	}

	private static void InvokeHammerAssetMethod( string methodName, Asset asset )
	{
		if ( asset is null )
			return;

		var method = GetHammerType()?.GetMethod( methodName, StaticFlags, binder: null, types: new[] { typeof( Asset ) }, modifiers: null );
		if ( method is null )
			return;

		try
		{
			method.Invoke( null, new object[] { asset } );
		}
		catch
		{
			// This dock still works as a browser if Hammer is not the active editor surface.
		}
	}

	private static Type GetHammerType()
	{
		if ( HasResolvedHammerType )
			return HammerType;

		HasResolvedHammerType = true;

		foreach ( var assembly in AppDomain.CurrentDomain.GetAssemblies() )
		{
			HammerType = assembly.GetType( "Editor.MapEditor.Hammer" );
			if ( HammerType is not null )
				break;
		}

		return HammerType;
	}
}