Editor/Tool/ArchLayersDockPanel.cs

Editor dock panel and UI components for the architecture "Plan Layers" panel. Provides a tree view of authored layers, drafts and built geometry, keeps the selection in sync with ArchTool picks, handles clicks, context menus, drag-and-drop reordering and grouping, and draws row UI (icons, controls, highlights, flashes).

File Access
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;
using static Editor.BaseItemWidget;

namespace Sunless.Architecture;

// The Plan Layers stack: the authored plan is the navigation surface, arranged like a paint stack.
// Rows come from the projected layer tree, never from generated geometry; a row click drives the
// same selection funnel as a viewport pick, so tree and viewport can never disagree.
[Dock( "Editor", "Plan Layers", "layers" )]
public sealed class ArchLayersDockPanel : Widget
{
	public const string DockName = "Plan Layers";

	readonly ArchLayerStack stack;
	readonly Layout header;
	readonly Label breadcrumb;

	ArchLayerTree projected = new();
	HashSet<int> built = new();

	// A row click has already set the selection the user asked for, ctrl and shift included, so the pick
	// that follows it must not stamp one row back over the lot.
	bool pickedInStack;

	bool trackedActive;
	bool trackedGeometry;
	ArchPlan trackedPlan;
	int trackedRevision;
	int trackedDrafts;
	int trackedAssemblies;
	object trackedPicked;

	public ArchLayersDockPanel( Widget parent ) : base( parent )
	{
		Layout = Layout.Column();
		Layout.Margin = 4;
		Layout.Spacing = 4;

		header = Layout.AddRow();
		header.Spacing = 2;

		breadcrumb = new Label( "Nothing picked" );
		breadcrumb.WordWrap = true;
		breadcrumb.MinimumHeight = 16;
		Layout.Add( breadcrumb );

		stack = new ArchLayerStack( this );
		stack.ItemClicked += OnItemClicked;
		Layout.Add( stack, 1 );

		BuildHeader();
	}

	public static void Open()
	{
		EditorWindow.DockManager.SetDockState( DockName, true );
		EditorWindow.DockManager.RaiseDock( DockName );
	}

	public ArchLayerTree Projected => projected;

	[EditorEvent.Frame]
	void OnFrame()
	{
		var tool = ArchTool.Active;
		var plan = tool?.Plan;
		var picked = tool?.Picked?.Item;
		var drafts = tool?.Drafts.Count( draft => draft.Standing ) ?? 0;
		var assemblies = plan?.Assemblies.Count ?? 0;

		// The geometry column is a static preference, so it can be turned on from outside this panel - a viewport
		// face pick does exactly that - and the rows only exist because a rebuild put them there.
		var planChanged = (tool is not null) != trackedActive
			|| ArchTool.ShowGeometry != trackedGeometry
			|| !ReferenceEquals( plan, trackedPlan )
			|| (tool?.Revision ?? 0) != trackedRevision
			|| drafts != trackedDrafts
			|| assemblies != trackedAssemblies;

		trackedActive = tool is not null;
		trackedGeometry = ArchTool.ShowGeometry;
		trackedPlan = plan;
		trackedRevision = tool?.Revision ?? 0;
		trackedDrafts = drafts;
		trackedAssemblies = assemblies;

		if ( planChanged )
		{
			Rebuild();
		}

		// A flash fades over real time, so the stack has to keep repainting while one is alive.
		if ( tool?.Flashing == true )
		{
			stack.Update();
		}

		var inStack = pickedInStack;

		pickedInStack = false;

		SyncFaces( tool, inStack );

		if ( ReferenceEquals( picked, trackedPicked ) )
		{
			return;
		}

		trackedPicked = picked;

		if ( inStack )
		{
			UpdateBreadcrumb( projected.Find( picked ) );

			return;
		}

		SyncSelection();
	}

	// The tool OWNS the pick and the stack only shows it. The stack may write it on the one frame the user
	// clicked in the stack, and never otherwise: reading its selection back every frame instead made anything
	// that cleared the stack behind this - a folded-away geometry column, an element pick landing the same
	// frame - read as a deselection, and a face picked in the viewport was gone again a frame later.
	void SyncFaces( ArchTool tool, bool inStack )
	{
		if ( tool is null )
		{
			return;
		}

		if ( inStack )
		{
			tool.SelectFaces( Wanted() );

			return;
		}

		if ( Same( tool.DebugFaces, Wanted() ) )
		{
			return;
		}

		Reveal( tool );
	}

	// A picked piece or lump row means every face under it.
	List<ArchFaceDetail> Wanted()
	{
		var picked = stack.SelectedItems.ToList();

		return picked
			.OfType<ArchFacePiece>()
			.SelectMany( piece => piece.Faces )
			.Concat( picked.OfType<ArchFaceGroup>().SelectMany( group => group.Every ) )
			.Concat( picked.OfType<ArchFaceDetail>() )
			.Distinct()
			.ToList();
	}

	static bool Same( IReadOnlyList<ArchFaceDetail> left, IReadOnlyList<ArchFaceDetail> right )
	{
		return left.Count == right.Count && left.All( right.Contains );
	}

	// Opens the way down to the faces the viewport picked - each one's layer ancestors, its GEOMETRY row,
	// its piece and whatever lump holds it - then makes the stack's selection EQUAL the tool's pick.
	// Selecting only the last one was the bug behind "ctrl+click will not pick two": the next frame read
	// the stack back and collapsed the pick to that single row. Only possible at all because the tool owns
	// the probe, so the row's face and the picked face are the same object.
	bool Reveal( ArchTool tool )
	{
		// Folded away, so there is no row to reveal it on and no business clearing a selection this is not the
		// one holding - the layer the viewport picked is still sitting in it.
		if ( !ArchTool.ShowGeometry )
		{
			return false;
		}

		if ( tool.DebugFaces.Count == 0 )
		{
			stack.UnselectAll();

			return false;
		}

		foreach ( var face in tool.DebugFaces )
		{
			if ( projected.Find( face.LayerId ) is not { } node )
			{
				continue;
			}

			for ( var ancestor = node; ancestor is not null; ancestor = ancestor.Parent )
			{
				stack.Open( projected.StableKey( ancestor ) );
			}

			stack.Open( (face.LayerId, "geometry") );

			foreach ( var piece in tool.BuiltFaces( face.LayerId ).Where( piece => piece.Faces.Contains( face ) ) )
			{
				stack.Open( piece );

				foreach ( var group in Holding( piece.Groups, face ) )
				{
					stack.Open( group );
				}
			}
		}

		stack.SelectItems( tool.DebugFaces, false );
		stack.ScrollTo( tool.DebugFaces[^1] );

		return true;
	}

	static IEnumerable<ArchFaceGroup> Holding( IEnumerable<ArchFaceGroup> groups, ArchFaceDetail face )
	{
		foreach ( var group in groups.Where( group => group.Holds( face ) ) )
		{
			yield return group;

			foreach ( var child in Holding( group.Children, face ) )
			{
				yield return child;
			}
		}
	}

	void BuildHeader()
	{
		header.Clear( true );

		header.Add( Tool( "add", "Add a layer inside the insertion target", AddMenu ) );
		header.Add( Tool( "create_new_folder", "Group the selected layer - a group is the scope an operation inside it may reach", GroupPicked ) );
		header.Add( Tool( "folder_off", "Dissolve the selected group; its members keep their own homes", UngroupPicked ) );
		header.Add( Tool( "visibility", "Show every layer again", () => ArchTool.Active?.ShowEveryLayer() ) );

		var isolate = new IconButton( "filter_center_focus" )
		{
			ToolTip = "Fold away the groups the current edit is not happening inside",
			IconSize = 15,
			FixedSize = 22,
			IsToggle = true,
			IsActive = ArchTool.IsolateGroups
		};

		isolate.OnToggled = value =>
		{
			ArchTool.IsolateGroups = value;
			stack.Update();
		};

		header.Add( isolate );

		// Classic Material Icons only - the editor's font has no Material Symbols glyphs, and a missing
		// one renders as an empty box rather than failing.
		var geometry = new IconButton( "widgets" )
		{
			ToolTip = "Show what each layer BUILT - its pieces and their faces, for picking one and copying its debug info",
			IconSize = 15,
			FixedSize = 22,
			IsToggle = true,
			IsActive = ArchTool.ShowGeometry
		};

		geometry.OnToggled = value =>
		{
			ArchTool.ShowGeometry = value;

			if ( !value )
			{
				ArchTool.Active?.SelectFaces( null );
			}

			Rebuild();
		};

		header.Add( geometry );
		header.AddStretchCell();
	}

	static IconButton Tool( string icon, string tooltip, Action clicked )
	{
		var button = new IconButton( icon ) { ToolTip = tooltip, IconSize = 15, FixedSize = 22 };

		button.OnClick = () => clicked();

		return button;
	}

	// The projection is metadata-only, so a full rebuild on every commit is cheap; open and selected
	// state survives because every row's Value is a stable identity, not the rebuilt node.
	void Rebuild()
	{
		projected = ArchTool.Active?.LayerTree ?? new ArchLayerTree();
		built = ArchTool.Active is { } tool ? tool.BuiltMeshes.Keys.ToHashSet() : new HashSet<int>();

		// The selection holds row VALUES, and a rebuild mints new face objects - left alone, the stack
		// stays selected on faces no row shows any more and the viewport keeps highlighting them.
		stack.UnselectAll();
		stack.Clear();

		foreach ( var group in projected.Domains )
		{
			var domain = new ArchDomainRow( group.Name, group.Domain );
			stack.AddItem( domain );

			foreach ( var child in group.Children )
			{
				domain.AddItem( RowFor( child ) );
			}

			foreach ( var draft in RootDrafts( group.Domain ) )
			{
				domain.AddItem( new ArchDraftRow( draft ) );
			}
		}

		trackedPicked = null;
	}

	ArchLayerRow RowFor( ArchLayerNode node )
	{
		var row = new ArchLayerRow( node, projected.StableKey( node ) );

		foreach ( var child in node.Children )
		{
			row.AddItem( RowFor( child ) );
		}

		// A draft names the parent it will land in before any plan edit exists to show it.
		foreach ( var draft in DraftsUnder( node.Ref?.ItemId ?? 0 ) )
		{
			row.AddItem( new ArchDraftRow( draft ) );
		}

		// What this layer actually BUILT, nested the way the generator named it - the fascia under the gutters
		// under the roof - so a fitting is reached by opening the thing it hangs on rather than hunted for.
		foreach ( var piece in ArchTool.Active?.PiecesOf( node.Ref?.ItemId ?? 0 ) ?? Array.Empty<ArchBuiltPiece>() )
		{
			row.AddItem( new ArchPieceRow( piece ) );
		}

		if ( ArchTool.ShowGeometry && node.Ref is { } layer && built.Contains( layer.ItemId ) )
		{
			row.AddItem( new ArchGeometryHeadRow( layer.ItemId ) );
		}

		return row;
	}

	static IEnumerable<ArchDraft> DraftsUnder( int parentId )
	{
		return ArchTool.Active?.Drafts
			.Where( draft => draft.Active && draft.Standing && (draft.Parent?.ItemId ?? 0) == parentId )
			?? Enumerable.Empty<ArchDraft>();
	}

	static IEnumerable<ArchDraft> RootDrafts( ArchLayerDomain domain )
	{
		var kinds = ArchKinds.Load();

		return ArchTool.Active?.Drafts
			.Where( draft => draft.Active && draft.Standing
				&& (draft.Parent is null || draft.Parent.Value.ItemId == 0)
				&& kinds.Domain( draft.Kind ) == domain )
			?? Enumerable.Empty<ArchDraft>();
	}

	void OnItemClicked( object obj )
	{
		pickedInStack = true;

		// Clicking anything that is not geometry drops the face pick outright, and drops the rows holding
		// it with it. Left to the tree's own bookkeeping, values whose rows are long gone stayed in the
		// selection and the highlight never let go.
		if ( obj is not (ArchGeometryFaceRow or ArchGeometryGroupRow or ArchGeometryPieceRow) )
		{
			stack.UnselectAll();
			ArchTool.Active?.SelectFaces( null );
		}

		if ( obj is ArchLayerRow row )
		{
			ApplySelection( row.Node );
		}
	}

	void ApplySelection( ArchLayerNode node )
	{
		if ( ArchTool.Active is not { } tool )
		{
			return;
		}

		// An anchor row names a link to somewhere else; clicking it goes there.
		if ( node.Payload is ArchLayerReference reference )
		{
			if ( projected.Find( reference.TargetId ) is { } target )
			{
				tool.SelectLayer( target );
			}

			return;
		}

		// Story headers step the view; only payload rows become selections.
		if ( node.Payload is null )
		{
			if ( node.Kind == ArchKind.Story && node.Building is { } storyBuilding )
			{
				tool.Level = node.Floor;
				tool.ActiveBuildingId = storyBuilding.Id;
			}

			return;
		}

		tool.SelectLayer( node );
	}

	// Only for a pick made somewhere else - the viewport, or a tool. A pick the stack itself made is
	// already on screen the way the user clicked it.
	void SyncSelection()
	{
		var node = projected.Find( ArchTool.Active?.Picked?.Item );

		UpdateBreadcrumb( node );

		// A face pick owns the rows while it lives, so the element only says where it is. Stamping its own row
		// over them dropped the faces, and the empty stack then read back as a deselection.
		if ( ArchTool.Active?.DebugFaces.Count > 0 )
		{
			return;
		}

		stack.UnselectAll();

		if ( node is not null )
		{
			stack.SelectItem( projected.StableKey( node ) );
		}
	}

	void UpdateBreadcrumb( ArchLayerNode selected )
	{
		var tool = ArchTool.Active;

		if ( tool is null )
		{
			breadcrumb.Text = "No Architecture tool active";
			return;
		}

		var target = tool.InsertionTarget is { } insertion && projected.Find( insertion.ItemId ) is { } targetNode
			? targetNode.DisplayName
			: "—";

		breadcrumb.Text = selected is null
			? $"Nothing picked · into {target}"
			: $"{projected.Breadcrumb( selected )} · into {target}";
	}

	void AddMenu()
	{
		if ( ArchTool.Active is not { } tool )
		{
			return;
		}

		var kinds = ArchKinds.Load();
		var parent = tool.InsertionTarget ?? tool.SelectedLayer;
		var menu = new ContextMenu( this );

		foreach ( var kind in Offered( kinds, parent ) )
		{
			var captured = kind;

			menu.AddOption( kinds.Label( captured ), kinds.Glyph( captured ),
				() => tool.BeginChild( parent ?? default, captured ) );
		}

		menu.OpenAtCursor();
	}

	// The root kinds are asked of the table, never named here, so a kind a module brings roots without core knowing it.
	static IReadOnlyList<ArchKind> Offered( ArchKinds kinds, ArchLayerRef? parent )
	{
		var offered = parent is { } layer
			? ArchLayerRules.ValidChildren( layer.Kind )
			: kinds.RootKinds();

		return offered.Where( kind => kinds.Subtool( kind ).Length > 0 ).ToList();
	}

	// Every selected row, not just the picked one - grouping several layers is what ctrl-clicking them
	// is for, and taking only the last one made the gesture look broken.
	void GroupPicked()
	{
		if ( ArchTool.Active is not { } tool )
		{
			return;
		}

		var members = stack.SelectedItems
			.Select( item => projected.Find( item ) )
			.Where( node => node?.Ref is not null )
			.Select( node => node.Ref.Value.ItemId )
			.ToList();

		if ( members.Count == 0 && tool.SelectedLayer is { } layer )
		{
			members.Add( layer.ItemId );
		}

		tool.GroupLayers( members );
	}

	void UngroupPicked()
	{
		if ( ArchTool.Active is not { } tool )
		{
			return;
		}

		var group = tool.Picked?.Item as ArchSiteAssembly
			?? ArchLayerGroups.Holding( tool.Plan, tool.SelectedLayer?.ItemId ?? 0 );

		tool.UngroupLayer( group );
	}
}

// The stack itself. Rows carry their own controls, so a press has to say whether it landed on the
// eye, the lock or the row before the tree decides it was a selection.
sealed class ArchLayerStack : TreeView
{
	public const float ControlWidth = 22f;

	public ArchLayerStack( Widget parent ) : base( parent )
	{
		// A z-fight is between TWO faces, so more than one row has to be pickable at a time.
		MultiSelect = true;
	}

	protected override bool OnItemPressed( VirtualWidget item, MouseEvent e )
	{
		if ( item.Object is ArchPieceRow piece && ArchTool.Active is { } owner )
		{
			return Pressed( piece, owner, item, e );
		}

		if ( item.Object is not ArchLayerRow row || row.Node.Ref is not { } layer || ArchTool.Active is not { } tool )
		{
			return base.OnItemPressed( item, e );
		}

		var local = e.LocalPosition.x - item.Rect.Left;
		var fromRight = item.Rect.Width - local;

		if ( fromRight <= ControlWidth )
		{
			tool.ToggleLayerLocked( layer );

			return false;
		}

		if ( fromRight <= ControlWidth * 2f )
		{
			tool.ToggleLayerHidden( layer.ItemId );
			Update();

			return false;
		}

		// The badge opens the same menu the right-click does - it is a way IN to the choice, not a fourth thing that
		// silently cycles through four modes.
		if ( fromRight <= ControlWidth * 3f && row.Node.Payload is IArchCollides )
		{
			var menu = new ContextMenu( this );

			row.Collisions( menu, tool );
			menu.OpenAtCursor();

			return false;
		}

		return base.OnItemPressed( item, e );
	}

	// A piece has no lock, so column 0 is left blank and the other two line up with the layer rows above it.
	bool Pressed( ArchPieceRow row, ArchTool tool, VirtualWidget item, MouseEvent e )
	{
		var fromRight = item.Rect.Width - (e.LocalPosition.x - item.Rect.Left);

		if ( fromRight <= ControlWidth )
		{
			return false;
		}

		if ( fromRight <= ControlWidth * 2f )
		{
			tool.TogglePieceHidden( row.Built.Owner, row.Built.Piece );
			Update();

			return false;
		}

		if ( fromRight <= ControlWidth * 3f )
		{
			var menu = new ContextMenu( this );

			row.Collisions( menu, tool );
			menu.OpenAtCursor();

			return false;
		}

		return base.OnItemPressed( item, e );
	}
}

// One row per authored layer. Value is the stable identity - the payload itself for authored rows,
// a synthetic story key for virtual ones - so the tree keeps open and selected state across rebuilds.
sealed class ArchLayerRow : TreeNode
{
	public ArchLayerNode Node { get; }

	public ArchLayerRow( ArchLayerNode node, object key ) : base( key )
	{
		Node = node;
		Height = 22;
	}

	bool IsGroup => Node.Payload is ArchSiteAssembly;

	int ItemId => Node.Ref?.ItemId ?? 0;

	public override void OnPaint( VirtualWidget item )
	{
		var tool = ArchTool.Active;
		var kinds = ArchKinds.Load();
		var rect = item.Rect;

		PaintSelection( item );

		// A folder reads as a band so the scope it draws around its members is visible at a glance.
		if ( IsGroup )
		{
			Paint.ClearPen();
			Paint.SetBrush( Theme.Blue.WithAlpha( 0.12f ) );
			Paint.DrawRect( rect, 2f );
		}

		// The flash: what the last edit reached, fading out, so an edit never lands invisibly.
		var flash = tool?.Flash( ItemId ) ?? 0f;

		if ( flash > 0f )
		{
			Paint.ClearPen();
			Paint.SetBrush( Theme.Green.WithAlpha( 0.45f * flash ) );
			Paint.DrawRect( rect, 2f );
		}

		// The insertion target wears a persistent left-edge accent - the tree's answer to "where
		// will the next thing I place go?".
		if ( tool?.InsertionTarget is { } target && target.ItemId == ItemId )
		{
			Paint.ClearPen();
			Paint.SetBrush( Theme.Primary );
			Paint.DrawRect( new Rect( rect.Left, rect.Top, 3f, rect.Height ), 0 );
		}

		var muted = tool?.Muted( Node ) == true;
		var hidden = tool?.LayerHidden( ItemId ) == true;
		var alpha = muted || hidden ? 0.28f : Node.Enabled ? 1f : 0.45f;

		Paint.SetPen( Theme.Text.WithAlpha( alpha ) );
		Paint.DrawIcon( rect.Shrink( 4, 0, 0, 0 ), kinds.Glyph( Node.Kind ), 15, TextFlag.LeftCenter );

		var controls = ArchLayerStack.ControlWidth * 4f;
		var body = rect.Shrink( 24, 0, controls + 4f, 0 );

		Paint.DrawText( body, Node.DisplayName, TextFlag.LeftCenter );

		Paint.SetPen( Theme.Text.WithAlpha( 0.3f * alpha ) );
		Paint.DrawText( body, Node.Stage.ToString().ToLowerInvariant(), TextFlag.RightCenter );

		Collision( rect );
		Palette( rect, alpha );

		Paint.SetPen( Theme.Text.WithAlpha( hidden ? 0.75f : 0.3f ) );
		Paint.DrawIcon( Control( rect, 1 ), hidden ? "visibility_off" : "visibility", 14, TextFlag.Center );

		Paint.SetPen( Node.Locked ? Theme.Yellow : Theme.Text.WithAlpha( 0.3f ) );
		Paint.DrawIcon( Control( rect, 0 ), Node.Locked ? "lock" : "lock_open", 14, TextFlag.Center );
	}

	void Palette( Rect rect, float alpha )
	{
		if ( !ArchLayerPalettePicker.HasOverride( Node ) )
		{
			return;
		}

		Paint.SetPen( Theme.Primary.WithAlpha( alpha ) );
		Paint.DrawIcon( Control( rect, 3 ), "palette", 14, TextFlag.Center );
	}

	// Dim while the layer is following whatever is above it, lit once it has been given an answer of its own - so the
	// column reads at a glance as "these few are different" rather than as one more icon on every row.
	void Collision( Rect rect )
	{
		if ( Node.Payload is not IArchCollides )
		{
			return;
		}

		var mode = ArchCollision.Showing( Node, ArchTool.Active?.Kit, out var own );

		Paint.SetPen( !own
			? Theme.Text.WithAlpha( 0.3f )
			: mode == ArchCollisionMode.Complex ? Theme.Yellow : Theme.Blue );

		Paint.DrawIcon( Control( rect, 2 ), ArchCollision.Glyph( mode ), 14, TextFlag.Center );
	}

	static Rect Control( Rect row, int fromRight )
	{
		var width = ArchLayerStack.ControlWidth;

		return new Rect( row.Right - width * (fromRight + 1), row.Top, width, row.Height );
	}

	public override string GetTooltip()
	{
		var problems = ArchTool.Active?.LayerTree.Problems( Node );

		return problems is { Count: > 0 }
			? problems[0]
			: $"{ArchKindsAsked.Label( Node.Kind )} · {Node.Stage} · id {ItemId}"
				+ (ArchLayerPalettePicker.HasOverride( Node ) ? " · palette override" : "");
	}

	public override void OnActivated()
	{
		if ( ArchTool.Active is { } tool && Node.Payload is not null )
		{
			tool.SelectLayer( Node );
			tool.Frame();
		}
	}

	public override bool OnDragStart()
	{
		if ( Node.Payload is null )
		{
			return false;
		}

		var drag = new Drag( TreeView );

		drag.Data.Object = Node;
		drag.Execute();

		return true;
	}

	// Dropping ON a row nests into it; dropping on its top or bottom edge reorders beside it. Both
	// go through the plan, never through the projection, so the tree cannot show a lie.
	public override DropAction OnDragDrop( ItemDragEvent e )
	{
		if ( e.Data.Object is not ArchLayerNode moving || moving.Ref is not { } layer || ArchTool.Active is not { } tool )
		{
			return DropAction.Ignore;
		}

		if ( ReferenceEquals( moving, Node ) || Descends( Node, moving ) )
		{
			return DropAction.Ignore;
		}

		var beside = e.DropEdge.HasFlag( ItemEdge.Top ) || e.DropEdge.HasFlag( ItemEdge.Bottom );

		if ( beside )
		{
			if ( !e.IsDrop )
			{
				return DropAction.Move;
			}

			if ( tool.LayerTree.Reorder( tool.Plan, layer, ItemId, e.DropEdge.HasFlag( ItemEdge.Bottom ) ) )
			{
				tool.Affect( layer.ItemId );
				tool.Commit( $"Reorder {moving.Name}" );
			}

			return DropAction.Move;
		}

		if ( !IsGroup && !ArchLayerRules.CanParent( Node.Kind, layer.Kind ).Allowed )
		{
			return DropAction.Ignore;
		}

		if ( e.IsDrop && tool.LayerTree.Reparent( tool.Plan, layer, ItemId ) )
		{
			tool.Affect( layer.ItemId );
			tool.Commit( $"Move {moving.Name} into {Node.Name}" );
		}

		return DropAction.Move;
	}

	static bool Descends( ArchLayerNode node, ArchLayerNode ancestor )
	{
		for ( var current = node; current is not null; current = current.Parent )
		{
			if ( ReferenceEquals( current, ancestor ) )
			{
				return true;
			}
		}

		return false;
	}

	// What this layer collides as, set where the layer lives. The one option that is not a mode is "follow the kit",
	// which is what every layer is until somebody says otherwise.
	public void Collisions( ContextMenu menu, ArchTool tool )
	{
		if ( Node.Payload is not IArchCollides host )
		{
			return;
		}

		var showing = ArchCollision.Showing( Node, tool.Kit, out _ );
		var collisions = menu.AddMenu( "Collisions", ArchCollision.Glyph( showing ) );

		// Named for what it actually follows, so clearing an override says where the answer will come from instead.
		var following = ArchCollision.Above( Node ) is { } parent
			? $"Follow the layer above — {ArchCollision.Describe( parent )}"
			: $"Follow the kit — {ArchCollision.Describe( tool.Kit?.Physics ?? ArchCollisionMode.Solids )}";

		collisions.AddOption( following, "settings", () => Collide( tool, host, null ) );

		collisions.AddSeparator();

		foreach ( var mode in new[] { ArchCollisionMode.Solids, ArchCollisionMode.Convex, ArchCollisionMode.Complex, ArchCollisionMode.None } )
		{
			var captured = mode;

			collisions.AddOption( ArchCollision.Describe( captured ), ArchCollision.Glyph( captured ),
				() => Collide( tool, host, captured ) );
		}
	}

	// The whole SUBTREE is what this reaches, because everything under it that has no answer of its own was following
	// this one - so the scope that gets dirtied is the group holding it, not the row that was clicked.
	void Collide( ArchTool tool, IArchCollides host, ArchCollisionMode? mode )
	{
		host.Collision = mode;

		tool.Affect( ArchLayerGroups.Holding( tool.Plan, ItemId )?.Id ?? ItemId );
		tool.Commit( $"Collisions {Node.Name}" );
	}

	public override bool OnContextMenu()
	{
		if ( ArchTool.Active is not { } tool || Node.Payload is null || Node.Ref is not { } layer )
		{
			return false;
		}

		var menu = new ContextMenu( TreeView );

		menu.AddOption( "Set as insertion target", "playlist_add", () => tool.InsertionTarget = layer );
		menu.AddOption( Node.Enabled ? "Disable generation" : "Enable generation",
			Node.Enabled ? "toggle_off" : "toggle_on", () => tool.ToggleLayerEnabled( layer ) );
		menu.AddOption( Node.Locked ? "Unlock" : "Lock", Node.Locked ? "lock_open" : "lock",
			() => tool.ToggleLayerLocked( layer ) );

		Collisions( menu, tool );

		if ( ArchLayerPalettePicker.CanOpen( tool, Node ) )
		{
			menu.AddOption( "Apply Palette", "palette", () => ArchLayerPalettePicker.Show( TreeView, tool, Node ) );
		}

		if ( Node.Payload is ArchCutPart { ResolvedDamage: ArchDamageKind.Masonry } cut )
		{
			menu.AddOption( "Bake to Target", "fit_screen", () =>
			{
				if ( !ArchDamage.BakeToTarget( tool.Plan, tool.EnsureKit(), cut ) )
				{
					Log.Warning( "Architecture: damage zone does not overlap a wall corner or pillar." );
					return;
				}

				tool.Affect( layer.ItemId );
				tool.Commit( "Bake Damage to Target" );
			} );
		}

		menu.AddSeparator();

		var kinds = ArchKinds.Load();
		var add = menu.AddMenu( "Add child", "add" );

		foreach ( var kind in ArchLayerRules.ValidChildren( Node.Kind ).Where( kind => kinds.Subtool( kind ).Length > 0 ) )
		{
			var captured = kind;

			add.AddOption( kinds.Label( captured ), kinds.Glyph( captured ),
				() => tool.BeginChild( layer, captured ) );
		}

		if ( IsGroup )
		{
			menu.AddOption( "Ungroup", "folder_off", () => tool.UngroupLayer( (ArchSiteAssembly)Node.Payload ) );
		}
		else
		{
			menu.AddOption( "Group", "create_new_folder", () => tool.GroupLayers( new[] { layer.ItemId } ) );

			if ( ArchLayerGroups.Holding( tool.Plan, layer.ItemId ) is not null )
			{
				menu.AddOption( "Take out of its group", "output", () =>
				{
					ArchLayerGroups.Leave( tool.Plan, layer.ItemId );
					tool.Affect( layer.ItemId );
					tool.Commit( $"Take {Node.Name} out of its group" );
				} );
			}
		}

		menu.AddSeparator();
		menu.AddOption( "Frame", "filter_center_focus", () => { tool.SelectLayer( Node ); tool.Frame(); } );
		menu.AddOption( "Delete", "delete", () => { tool.SelectLayer( Node ); tool.DeleteSelected(); } );

		menu.OpenAtCursor();

		return true;
	}
}

// A placement in flight: cyan, never in the plan until Finish.
sealed class ArchDraftRow : TreeNode
{
	public ArchDraft Draft { get; }

	public ArchDraftRow( ArchDraft draft ) : base( draft )
	{
		Draft = draft;
		Height = 22;
	}

	public override void OnPaint( VirtualWidget item )
	{
		var kinds = ArchKinds.Load();

		Paint.SetPen( Color.Cyan );
		Paint.DrawIcon( item.Rect.Shrink( 4, 0, 0, 0 ), kinds.Glyph( Draft.Kind ), 15, TextFlag.LeftCenter );
		Paint.DrawText( item.Rect.Shrink( 24, 0, 0, 0 ), $"{Draft.Name} — placing", TextFlag.LeftCenter );
	}
}

// The domain head. Dropping a layer here takes it out of whatever group was holding it.
sealed class ArchDomainRow : TreeNode
{
	readonly string label;
	readonly string glyph;

	public ArchDomainRow( string label, ArchLayerDomain domain ) : base( domain )
	{
		this.label = label;
		glyph = domain switch
		{
			ArchLayerDomain.Buildings => "domain",
			ArchLayerDomain.Connections => "link",
			_ => "route"
		};
		Height = 24;
	}

	public override void OnPaint( VirtualWidget item )
	{
		Paint.SetPen( Theme.Text.WithAlpha( 0.85f ) );
		Paint.DrawIcon( item.Rect.Shrink( 4, 0, 0, 0 ), glyph, 15, TextFlag.LeftCenter );
		Paint.DrawText( item.Rect.Shrink( 24, 0, 0, 0 ), label.ToUpperInvariant(), TextFlag.LeftCenter );
	}

	public override DropAction OnDragDrop( ItemDragEvent e )
	{
		if ( e.Data.Object is not ArchLayerNode moving || moving.Ref is not { } layer || ArchTool.Active is not { } tool )
		{
			return DropAction.Ignore;
		}

		if ( ArchLayerGroups.Holding( tool.Plan, layer.ItemId ) is null )
		{
			return DropAction.Ignore;
		}

		if ( e.IsDrop )
		{
			ArchLayerGroups.Leave( tool.Plan, layer.ItemId );
			tool.Affect( layer.ItemId );
			tool.Commit( $"Take {moving.Name} out of its group" );
		}

		return DropAction.Move;
	}
}