Editor/EffigyEditor/EffigyMaterialSlot.cs
using Editor;
using Sandbox;
using System;

namespace Marionette.EditorTools;

/// <summary>
/// One material slot, and the material assigned to it.
///
/// WHAT A SLOT ACTUALLY IS. Effigy's faces carry an integer, not a material — Face.Material is a
/// group number, and PartStudio.MaterialNames maps that number to a name which ObjWriter emits as
/// `usemtl`. The number is the thing geometry references and it has to stay an integer for that to
/// keep working across a rebuild. This control does not replace it; it fills in the other half, so
/// the name a slot carries is a real material asset path rather than "material_3".
///
/// ONE CONTROL, THREE HOMES. The same row appears beside the slot number in a feature's dialog and
/// once per slot in the Materials panel; the face right-click menu opens the same picker through
/// <see cref="Pick"/> rather than embedding the row, because a menu closes the instant you click
/// anything in it and would take the row — and the modal it parented — down with it.
///
/// Picking is per SLOT, not per face or per feature: assigning here repaints every face on that
/// slot across the whole part. That is the same thing Blender's material slots do, and it is what
/// makes painting faces worth doing at all.
/// </summary>
internal sealed class EffigyMaterialSlot : Widget
{
	private readonly int _slot;
	private readonly Editor.Label _name;
	private string _current;

	/// <summary>Called with the slot and its new material path, or null when cleared.</summary>
	public Action<int, string> Changed { get; set; }

	public EffigyMaterialSlot( Widget parent, int slot, string current, bool showSlotLabel = true ) : base( parent )
	{
		_slot = slot;
		_current = current;

		Layout = Layout.Row();
		Layout.Spacing = 6;

		if ( showSlotLabel )
			Layout.Add( new Editor.Label( $"Slot {slot}" ) { FixedWidth = 62 } );

		_name = new Editor.Label( Describe( current ) ) { Color = NameColour( current ) };
		Layout.Add( _name, 1 );

		var browse = new Button( "Browse..." ) { FixedWidth = 78 };
		browse.Clicked = () => Pick( this, _slot, _current, Apply );
		Layout.Add( browse );

		var clear = new Button( "×" ) { FixedWidth = 24, ToolTip = "Back to the default name for this slot" };
		clear.Clicked = () => Apply( _slot, null );
		Layout.Add( clear );

		Refresh( current );
	}

	/// <summary>Show a value that changed somewhere else — the same slot edited from the panel while
	/// its feature's dialog is open, or an undo.</summary>
	public void Refresh( string path )
	{
		_current = path;

		if ( !_name.IsValid() )
			return;

		_name.Text = Describe( path );
		_name.Color = NameColour( path );

		// The path is the tooltip because the label shows only the file name. Materials live several
		// folders deep and the full path is what tells two `metal.vmat`s apart, but it is also far
		// wider than any row this sits in.
		_name.ToolTip = string.IsNullOrWhiteSpace( path )
			? $"Nothing picked — exports as material_{_slot}"
			: path;
	}

	/// <summary>
	/// The editor's own material browser, exactly as the Hotspot editor opens it.
	///
	/// Static so the face menu can open it after closing itself, with nothing of this widget left
	/// alive. OnAssetPicked rather than OnAssetHighlighted: highlighting fires as you arrow through
	/// the list, and every one of those would be a studio edit and a rebuild.
	/// </summary>
	public static void Pick( Widget parent, int slot, string current, Action<int, string> picked )
	{
		var picker = AssetPicker.Create( parent, AssetType.Material );

		picker.Window.Title = $"Material for slot {slot}";
		picker.OnAssetPicked = assets =>
		{
			foreach ( var asset in assets )
			{
				// RelativePath, not Path or AbsolutePath. It is what the asset system resolves a
				// material by and what an exported OBJ's usemtl has to say to mean anything on
				// another machine; an absolute path is true only on this one.
				picked?.Invoke( slot, asset?.RelativePath );
				break;
			}
		};

		picker.Show();

		// Open the browser standing on the slot's current material rather than at the top of the
		// list, so re-picking is a nudge instead of a hunt. A slot still carrying a hand-typed name
		// resolves to nothing and the picker just opens unselected.
		if ( !string.IsNullOrWhiteSpace( current ) )
			picker.SetSelection( current );
	}

	private void Apply( int slot, string path )
	{
		Refresh( path );
		Changed?.Invoke( slot, path );
	}

	/// <summary>Nothing assigned reads as the default rather than as blank, because a slot always
	/// exports SOMETHING — ObjWriter falls back to material_N — and an empty label would suggest the
	/// face has no material at all.</summary>
	private string Describe( string path )
	{
		if ( string.IsNullOrWhiteSpace( path ) )
			return $"material_{_slot} (default)";

		// Only the last segment, and only when the value looks like a path at all: a document written
		// before this control existed carries whatever name somebody typed, and chopping "brushed
		// steel" at a slash it does not have would show it back unchanged anyway.
		var cut = path.LastIndexOfAny( new[] { '/', '\\' } );

		return cut >= 0 && cut < path.Length - 1 ? path[(cut + 1)..] : path;
	}

	private static Color NameColour( string path ) =>
		string.IsNullOrWhiteSpace( path ) ? Theme.TextControl.WithAlpha( 0.55f ) : Theme.Text;
}