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.";
}
}