Editor/SelectableColumn.cs
using System;
using System.Collections.Generic;

namespace HierarchyExtension;

/// <summary>
/// The Hierarchy's column of hands, one on each row just left of where the stock Hide shows its eye. A crossed hand marks
/// an object that can't be selected in the scene view (<see cref="Unselectable"/>); it's faint when that comes from a
/// parent. Hovering a row shows a faint hand to click. A click makes the row's object unselectable, or selectable again;
/// on a row that's unselectable through a parent, it frees that parent.
/// <para>
/// The stock Hierarchy has no way in for this: <c>GameObjectNode.OnPaint</c> paints each row whole, and
/// <c>GameObjectNode.AddGameObjectMenuItems</c> builds the row's context menu, with no event or interface to add to
/// either (tools source, <c>Scene/SceneTree</c>, 26.09.29). So this is a narrow strip laid over the right-hand end of the
/// Hierarchy's rows. It paints the hands and takes the clicks there; everywhere else the tree works as before.
/// </para>
/// </summary>
public sealed class SelectableColumn : Widget
{
	/// <summary>An icon's box, as wide as the stock eye's.</summary>
	const float IconWidth = 18;

	/// <summary>The stock eye's box and its gap: 18 wide, 4 in from the row's end (<c>GameObjectNode.OnPaint</c>).</summary>
	const float EyeWidth = 4 + 18;

	static SelectableColumn _current;

	/// <summary>The column on the Hierarchy in use, if it's open.</summary>
	internal static SelectableColumn Current => _current.IsValid() && _current._tree.IsValid() ? _current : null;

	readonly TreeView _tree;

	/// <summary>The Hierarchy's tree this column lies over.</summary>
	internal TreeView Tree => _tree;

	/// <summary>A hash of what was painted, so the strip repaints only when rows move or change.</summary>
	int _look;

	SelectableColumn( TreeView tree ) : base( tree )
	{
		_tree = tree;
		MouseTracking = true;
		Cursor = CursorShape.Finger;

		Place();
		Visible = true;
	}

	/// <summary>Puts one on the Hierarchy's tree, and again whenever the dock builds a new tree.</summary>
	[EditorEvent.Frame]
	static void Attach()
	{
		var tree = SceneTreeWidget.Current?.TreeView;
		if ( !tree.IsValid() ) return;
		if ( _current.IsValid() && _current._tree == tree ) return;

		_current = new SelectableColumn( tree );
	}

	[EditorEvent.Frame]
	void Frame()
	{
		if ( !_tree.IsValid() ) return;

		Place();

		var look = Look();
		if ( look == _look ) return;

		_look = look;
		Update();
	}

	/// <summary>
	/// Over the rows' right-hand end, just left of the eye's box, from the top down to the last row in view, so a click
	/// on the empty space under the rows still reaches the tree.
	/// </summary>
	void Place()
	{
		var right = _tree.CanvasRect.Right;
		var bottom = 0f;
		foreach ( var (row, _) in Rows() )
		{
			right = row.Rect.Right;
			bottom = row.Rect.Bottom;
		}

		var position = new Vector2( right - EyeWidth - IconWidth, 0 );
		var size = new Vector2( IconWidth, MathF.Min( bottom, _tree.Height ) );

		if ( Position != position ) Position = position;
		if ( Size != size ) Size = size;
	}

	/// <summary>The rows in view that show an object, top to bottom, in the tree's coordinates.</summary>
	internal IEnumerable<(VirtualWidget Row, GameObject Object)> Rows()
	{
		var x = _tree.CanvasRect.Left + 1;
		var y = 0f;

		while ( y < _tree.Height )
		{
			var row = _tree.GetItemAt( new Vector2( x, y ) );
			if ( row is null )
			{
				y += 2;
				continue;
			}

			if ( row.Object is TreeNode { Value: GameObject go } && go.IsValid() && go is not Scene )
				yield return (row, go);

			y = MathF.Max( y + 1, row.Rect.Bottom + 1 );
		}
	}

	/// <summary>The object on the row at <paramref name="y"/>, in this strip's coordinates.</summary>
	GameObject ObjectAt( float y )
	{
		var row = _tree.GetItemAt( new Vector2( _tree.CanvasRect.Left + 1, y + Position.y ) );
		return row?.Object is TreeNode { Value: GameObject go } && go.IsValid() && go is not Scene ? go : null;
	}

	/// <summary>The row under the cursor, in the tree or in this strip.</summary>
	internal VirtualWidget HoveredRow()
	{
		var cursor = _tree.FromScreen( Editor.Application.CursorPosition );
		if ( !_tree.LocalRect.IsInside( cursor ) ) return null;

		return _tree.GetItemAt( new Vector2( _tree.CanvasRect.Left + 1, cursor.y ) );
	}

	int Look()
	{
		var hash = new HashCode();
		hash.Add( Size );

		var hovered = HoveredRow();
		foreach ( var (row, go) in Rows() )
		{
			hash.Add( row.Rect.Top );
			hash.Add( go.Id );
			hash.Add( go.Active );
			hash.Add( Unselectable.IsSet( go ) );
			hash.Add( Unselectable.Has( go ) );
			hash.Add( row == hovered );
		}

		return hash.ToHashCode();
	}

	protected override void OnPaint()
	{
		var hovered = HoveredRow();

		foreach ( var (row, go) in Rows() )
		{
			var hand = Unselectable.IsSet( go ) ? Hand.Own : Unselectable.Has( go ) ? Hand.FromParent : row == hovered ? Hand.Offered : Hand.None;
			PaintHand( new Rect( 0, row.Rect.Top - Position.y, IconWidth, row.Rect.Height ), hand, go.Active );
		}
	}

	/// <summary>What a row's hand shows.</summary>
	internal enum Hand
	{
		/// <summary>Selectable, and not hovered: nothing.</summary>
		None,

		/// <summary>Selectable and hovered: a faint hand to click.</summary>
		Offered,

		/// <summary>Unselectable through a parent: a faint crossed hand.</summary>
		FromParent,

		/// <summary>Unselectable by its own tag: a crossed hand.</summary>
		Own,
	}

	/// <summary>Paints <paramref name="hand"/> in <paramref name="rect"/>, dimmed for a disabled object as its row's text is.</summary>
	internal static void PaintHand( Rect rect, Hand hand, bool active )
	{
		if ( hand == Hand.None ) return;

		var icon = hand == Hand.Offered ? "touch_app" : "do_not_touch";
		var alpha = hand == Hand.Own ? 0.9f : 0.35f;
		if ( !active ) alpha *= 0.5f;

		Paint.Pen = Theme.TextControl.WithAlpha( alpha );
		Paint.DrawIcon( rect, icon, 14, TextFlag.Center );
	}

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

		// taken here, so the row under the hand isn't selected as well
		e.Accepted = true;
		if ( !e.LeftMouseButton ) return;

		var go = ObjectAt( e.LocalPosition.y );
		if ( go is null ) return;

		if ( Unselectable.IsSet( go ) ) Unselectable.Set( new[] { go }, false );
		else if ( Unselectable.Source( go ) is { } source ) Unselectable.Set( new[] { source }, false );
		else Unselectable.Set( new[] { go }, true );

		ToolTip = TooltipFor( go );
		_look = 0;
		Update();
	}

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

		ToolTip = TooltipFor( ObjectAt( e.LocalPosition.y ) );
	}

	/// <summary>The strip lies over the tree, so the wheel over it scrolls the tree, the way the tree scrolls itself.</summary>
	protected override void OnMouseWheel( WheelEvent e )
	{
		if ( !_tree.SmoothScrolling )
		{
			e.Ignore();
			return;
		}

		e.Accept();
		_tree.SmoothScrollTarget += e.Delta < 0 ? _tree.VerticalScrollbar.SingleStep : -_tree.VerticalScrollbar.SingleStep;
	}

	static string TooltipFor( GameObject go )
	{
		if ( go is null ) return null;

		if ( Unselectable.IsSet( go ) )
			return "Not selectable in the scene view: clicks go through it. Click to make it selectable.";

		if ( Unselectable.Source( go ) is { } source )
			return $"Not selectable in the scene view, because {source.Name} isn't. Click to make {source.Name} selectable.";

		return "Click to make it not selectable in the scene view: clicks will go through it to what's behind.";
	}
}