Editor/Unselectable.cs
using System.Collections.Generic;
using System.Linq;

namespace HierarchyExtension;

/// <summary>
/// Objects that can't be selected in the scene view. A click goes through them to whatever is behind, and a drag
/// selection leaves them out. They still select from the Hierarchy, so they can be inspected and edited as usual.
/// <para>
/// An object is made unselectable with the <see cref="Tag"/> tag, the way the stock Hide marks hidden objects with
/// "hidden". It saves with the scene, shows in the object's Tags, takes undo, and counts as an override on a prefab
/// instance. Tags pass down to children, so everything under an unselectable object is unselectable too.
/// </para>
/// <para>
/// Set it with the hand on each Hierarchy row (<see cref="SelectableColumn"/>), with <b>Scene > View > Toggle
/// Selectable</b> (Alt+L) for the selected objects, or with <b>Make All Selectable</b>. <see cref="ScenePicking"/>
/// makes the scene view honour it.
/// </para>
/// </summary>
public static class Unselectable
{
	/// <summary>The tag that makes an object, and everything under it, unselectable in the scene view.</summary>
	public const string Tag = "unselectable";

	/// <summary>Whether <paramref name="go"/> can't be selected in the scene view, from its own tag or a parent's.</summary>
	public static bool Has( GameObject go ) => go.IsValid() && go is not Scene && go.Tags.Has( Tag );

	/// <summary>Whether <paramref name="go"/> has the tag itself, not only from a parent.</summary>
	public static bool IsSet( GameObject go ) => go.IsValid() && go is not Scene && go.Tags.Has( Tag, false );

	/// <summary>
	/// The object whose tag makes <paramref name="go"/> unselectable: itself, or the nearest parent with the tag.
	/// Null when it's selectable.
	/// </summary>
	public static GameObject Source( GameObject go )
	{
		for ( var o = go; o.IsValid() && o is not Scene; o = o.Parent )
		{
			if ( IsSet( o ) ) return o;
		}

		return null;
	}

	/// <summary>
	/// Make <paramref name="objects"/> unselectable in the scene view, or selectable again, as one undo step. Objects
	/// that are that way already are left alone. Making an object selectable only takes its own tag off, so one under
	/// an unselectable parent stays unselectable; pass <see cref="Source"/> to free it.
	/// </summary>
	public static void Set( IEnumerable<GameObject> objects, bool unselectable )
	{
		var changing = objects.Where( x => x.IsValid() && x is not Scene && IsSet( x ) != unselectable ).Distinct().ToArray();
		if ( changing.Length == 0 ) return;

		var session = SessionOf( changing[0] );
		if ( session is null ) return;

		var name = unselectable ? "Make Unselectable" : "Make Selectable";
		if ( changing.Length == 1 ) name = $"{name}: {changing[0].Name}";

		using var sceneScope = session.Scene.Push();

		// only the objects' own properties, which is where tags live: a tile layer's children and components can be big
		using ( session.UndoScope( name ).WithGameObjectChanges( changing, GameObjectUndoFlags.Properties ).Push() )
		{
			foreach ( var go in changing )
			{
				if ( unselectable ) go.Tags.Add( Tag );
				else go.Tags.Remove( Tag );
			}
		}
	}

	/// <summary>
	/// Alt+L: make the selected objects unselectable in the scene view or, when they all are already, selectable again.
	/// One that's unselectable through a parent frees that parent, and so everything under it.
	/// </summary>
	[Menu( "Editor", "Scene/View/Toggle Selectable", "do_not_touch" )]
	[Shortcut( "editor.toggle-selectable", "Alt+L" )]
	public static void ToggleSelected()
	{
		var session = SceneEditorSession.Active;
		if ( session is null ) return;

		var selected = session.Selection.OfType<GameObject>().Where( x => x.IsValid() && x is not Scene ).ToArray();
		if ( selected.Length == 0 ) return;

		if ( selected.Any( x => !Has( x ) ) ) Set( selected, true );
		else Set( selected.Select( Source ), false );
	}

	/// <summary>Make everything in the open scene selectable in the scene view again.</summary>
	[Menu( "Editor", "Scene/View/Make All Selectable", "touch_app" )]
	public static void MakeAllSelectable()
	{
		var scene = SceneEditorSession.Active?.Scene;
		if ( !scene.IsValid() ) return;

		Set( scene.GetAllObjects( false ).Where( IsSet ), false );
	}

	/// <summary>Whether anything in <paramref name="scene"/> is unselectable. Cheap enough to ask on every click.</summary>
	public static bool AnyIn( Scene scene )
	{
		if ( !scene.IsValid() ) return false;

		foreach ( var go in scene.GetAllObjects( true ) )
		{
			if ( IsSet( go ) ) return true;
		}

		return false;
	}

	/// <summary>The editor session whose scene holds <paramref name="go"/>, the play session's included.</summary>
	public static SceneEditorSession SessionOf( GameObject go ) => SessionOf( go?.Scene );

	/// <summary>The editor session of <paramref name="scene"/>, the play session's included.</summary>
	public static SceneEditorSession SessionOf( Scene scene )
	{
		if ( scene is null ) return null;

		return SceneEditorSession.All.FirstOrDefault( x => x.Scene == scene );
	}
}