Editor/Tool/ArchLayerInspectorPanel.cs

An editor UI panel for the architecture tool, showing properties for the currently picked layer or picked faces. It watches ArchTool state each frame and rebuilds a scrollable inspector with header, context, merge settings, validation messages, actions and an expandable properties sheet and faces debug list.

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

namespace Sunless.Architecture;

// The properties of whatever layer is picked, wherever it was picked from and whichever subtool is
// up. Selecting in the stack used to open nothing unless Select happened to be the active tool -
// this is the surface that makes a tree click an edit.
[Dock( "Editor", "Layer Inspector", "tune" )]
public sealed class ArchLayerInspectorPanel : Widget
{
	public const string DockName = "Layer Inspector";

	readonly Layout body;

	object trackedItem;
	int trackedRevision = int.MinValue;
	int trackedTarget = int.MinValue;
	int trackedFaces = int.MinValue;
	bool trackedActive;

	public ArchLayerInspectorPanel( Widget parent ) : base( parent )
	{
		Layout = Layout.Column();

		var scroll = new ScrollArea( this );
		scroll.Canvas = new Widget( scroll ) { Layout = Layout.Column() };
		scroll.Canvas.Layout.Margin = 8;
		scroll.Canvas.Layout.Spacing = 4;
		body = scroll.Canvas.Layout;

		Layout.Add( scroll, 1 );

		Rebuild();
	}

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

	[EditorEvent.Frame]
	void OnFrame()
	{
		var tool = ArchTool.Active;
		var item = tool?.Picked?.Item;
		var target = tool?.InsertionTarget?.ItemId ?? 0;

		var faces = tool?.DebugFaces.Count ?? 0;

		if ( ReferenceEquals( item, trackedItem )
			&& (tool is not null) == trackedActive
			&& (tool?.Revision ?? int.MinValue) == trackedRevision
			&& target == trackedTarget
			&& faces == trackedFaces )
		{
			return;
		}

		trackedItem = item;
		trackedActive = tool is not null;
		trackedRevision = tool?.Revision ?? int.MinValue;
		trackedTarget = target;
		trackedFaces = faces;

		Rebuild();
	}

	void Rebuild()
	{
		body.Clear( true );

		if ( ArchTool.Active is not { } tool )
		{
			body.Add( ArchPartUi.Wrapped( "Pick the Architecture or Roads tool in the scene view to edit this scene's plan." ) );
			body.AddStretchCell();

			return;
		}

		// Faces lead when there are any: they are only ever picked deliberately, to be read out.
		BuildFaces( tool );

		if ( tool.Picked is not { Item: not null } picked || tool.LayerTree.Find( picked.Item ) is not { } node )
		{
			if ( tool.DebugFaces.Count == 0 )
			{
				body.Add( ArchPartUi.Wrapped( "Nothing picked. Choose a layer in the Plan Layers stack, or click one in the viewport." ) );
			}

			body.AddStretchCell();

			return;
		}

		// Layer concerns only: what this is, where it stands, what it merges. Navigating to a child, framing
		// one and grouping one are all the stack's job - it draws the tree, so a second flattened copy of it
		// down here is one more surface saying the same thing in fewer words.
		BuildHeader( tool, node );
		BuildContext( tool, node );
		BuildMerge( tool, node );
		BuildValidation( tool, node );
		BuildActions( tool );
		BuildProperties( tool, picked );

		body.AddStretchCell();
	}

	void BuildHeader( ArchTool tool, ArchLayerNode node )
	{
		var path = new Label( tool.LayerTree.Breadcrumb( node ) );
		path.SetStyles( "color: rgba(255,255,255,0.45);" );
		path.WordWrap = true;
		body.Add( path );

		var title = body.AddRow();
		title.Spacing = 4;

		var name = new LineEdit( node.Name );

		name.TextEdited += text =>
		{
			if ( ArchKindsAsked.TryRename( node.Payload, text ) )
			{
				tool.Affect( node.Ref?.ItemId ?? 0 );
				tool.Touch( "Rename Layer" );
			}
		};

		title.Add( name, 1 );

		if ( node.Ref is { } layer )
		{
			title.Add( Toggle( node.Enabled ? "toggle_on" : "toggle_off",
				node.Enabled ? "Generating - click to exclude this layer and its effects" : "Excluded from generation",
				node.Enabled, () => tool.ToggleLayerEnabled( layer ) ) );

			title.Add( Toggle( node.Locked ? "lock" : "lock_open",
				node.Locked ? "Locked - handles and property edits refuse" : "Unlocked",
				node.Locked, () => tool.ToggleLayerLocked( layer ) ) );
		}

		var label = node.Payload is ArchCutPart { IsDamage: true } ? "Damage zone" : ArchKindsAsked.Label( node.Kind );
		var kind = new Label( $"{label} · {node.Stage}" );
		kind.SetStyles( "color: rgba(255,255,255,0.45);" );
		body.Add( kind );
	}

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

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

		return button;
	}

	// Where this layer stands and what it consumes - the sentence the placement bar says while placing.
	void BuildContext( ArchTool tool, ArchLayerNode node )
	{
		var parent = node.Parent;
		var group = ArchLayerGroups.Holding( tool.Plan, node.Ref?.ItemId ?? 0 );

		body.AddSeparator();
		body.Add( Line( "Parent", parent?.Name ?? "plan root" ) );
		body.Add( Line( "Host", ArchLayerRules.HostOutput( parent?.Kind ?? ArchKind.Building ) ) );

		if ( node.Floor != int.MinValue )
		{
			body.Add( Line( "Story", $"Level {node.Floor}" ) );
		}

		body.Add( Line( "Group", group?.Name ?? "none — it reaches the whole plan" ) );

		if ( node.Ref is { } layer )
		{
			var target = new Button( "Place new layers in here", "playlist_add" )
			{
				Clicked = () => tool.InsertionTarget = layer
			};

			body.Add( target );
		}
	}

	static Widget Line( string label, string value )
	{
		var holder = new Widget( null ) { Layout = Layout.Row() };
		holder.Layout.Spacing = 4;

		var name = new Label( label );
		name.MinimumWidth = 72;
		name.SetStyles( "color: rgba(255,255,255,0.45);" );

		var text = new Label( value );
		text.WordWrap = true;

		holder.Layout.Add( name );
		holder.Layout.Add( text, 1 );

		return holder;
	}

	// The terms of the join, edited from either end of it: the connector that does the merging, or
	// the group that is the merge. They are the same settings because the group is the relationship.
	void BuildMerge( ArchTool tool, ArchLayerNode node )
	{
		var group = node.Payload as ArchSiteAssembly
			?? ArchLayerGroups.Holding( tool.Plan, node.Ref?.ItemId ?? 0 );

		if ( group is null || group.Kind == ArchAssemblyKind.Group )
		{
			return;
		}

		if ( node.Payload is not ArchSiteAssembly && node.Kind != ArchKind.Walkway )
		{
			return;
		}

		var merge = group.Merge;

		Action changed = () => { tool.Affect( group.Id ); tool.Touch( "Merge Settings" ); };

		body.AddSeparator();
		body.Add( new Label( "MERGE" ) );
		body.Add( ArchPartUi.Check( "Open the walls it meets", merge.OpenWalls, value => { merge.OpenWalls = value; changed(); Rebuild(); } ) );

		if ( !merge.OpenWalls )
		{
			return;
		}

		body.Add( ArchPartUi.Check( "Near end", merge.NearEnd, value => { merge.NearEnd = value; changed(); } ) );
		body.Add( ArchPartUi.Check( "Far end", merge.FarEnd, value => { merge.FarEnd = value; changed(); } ) );
		body.Add( ArchPartUi.Number( "Mouth height", merge.Clearance, 0f, value => { merge.Clearance = value; changed(); } ) );
	}

	// Folded away by default: the part sheet is the authoring tool's surface, not the layer's, and
	// the choice sticks so nobody has to re-open it every time the selection changes.
	void BuildProperties( ArchTool tool, ArchSelection picked )
	{
		body.AddSeparator();

		var open = Expanded;

		var toggle = new Button( picked.Describe(), open ? "expand_less" : "expand_more" );

		toggle.Clicked = () => { Expanded = !open; Rebuild(); };

		body.Add( toggle );

		if ( !open )
		{
			return;
		}

		var sheet = new ToolSidebarWidget( null );

		ArchPartUi.Sheet( sheet, tool, picked, Rebuild );
		body.Add( sheet );
	}

	static bool Expanded
	{
		get => EditorCookie.Get( "arch.inspector.properties", false );
		set => EditorCookie.Set( "arch.inspector.properties", value );
	}

	// The picked faces, and the one button that hands them over. What it copies is what arch_faces
	// returns, so the paste and the query are the same report. Folds away because it sits above the
	// layer's own properties and a dozen face lines would push them off the panel.
	void BuildFaces( ArchTool tool )
	{
		if ( tool.DebugFaces.Count == 0 )
		{
			return;
		}

		var open = FacesExpanded;
		var head = body.AddRow();
		head.Spacing = 4;

		head.Add( Toggle( open ? "expand_less" : "expand_more",
			open ? "Hide the picked faces" : "Show the picked faces",
			open, () => { FacesExpanded = !open; Rebuild(); } ) );

		head.Add( new Label( $"FACES · {tool.DebugFaces.Count} picked" ), 1 );

		var brief = new IconButton( "smart_toy" )
		{
			ToolTip = "Copy the briefing - every picked face keyed by id, with a verdict. The form to paste to an agent.",
			IconSize = 15,
			FixedSize = 22
		};

		brief.OnClick = () => Log.Info( tool.CopyFaceDebug( true ) );

		head.Add( brief );

		var copy = new IconButton( "content_copy" )
		{
			ToolTip = "Copy the plain report - the same facts written out in prose",
			IconSize = 15,
			FixedSize = 22
		};

		copy.OnClick = () => Log.Info( tool.CopyFaceDebug() );

		head.Add( copy );

		if ( !open )
		{
			body.AddSeparator();

			return;
		}

		foreach ( var face in tool.DebugFaces.Take( 12 ) )
		{
			var line = new Label( $"{face.Piece.Split( '/' ).Last()} · {face.Label}" );
			line.WordWrap = true;
			line.SetStyles( "color: rgba(255,180,170,0.9);" );
			body.Add( line );
		}

		if ( tool.DebugFaces.Count > 12 )
		{
			body.Add( new Label( $"… and {tool.DebugFaces.Count - 12} more" ) );
		}

		body.Add( new Button.Primary( "Copy Briefing", "smart_toy" ) { Clicked = () => Log.Info( tool.CopyFaceDebug( true ) ) } );
		body.Add( new Button( "Copy Debug Info Plain", "content_copy" ) { Clicked = () => Log.Info( tool.CopyFaceDebug() ) } );
		body.AddSeparator();
	}

	static bool FacesExpanded
	{
		get => EditorCookie.Get( "arch.inspector.faces", true );
		set => EditorCookie.Set( "arch.inspector.faces", value );
	}

	void BuildValidation( ArchTool tool, ArchLayerNode node )
	{
		var problems = tool.LayerTree.Problems( node );

		if ( problems.Count == 0 )
		{
			return;
		}

		body.AddSeparator();

		foreach ( var problem in problems )
		{
			var warning = new Label( $"⚠ {problem}" );
			warning.WordWrap = true;
			warning.SetStyles( "color: #e0b040;" );
			body.Add( warning );
		}
	}

	// Delete alone. Framing and grouping are gestures on the tree - a row's own context menu and the stack's
	// header buttons - and having them twice only raises the question of whether the two do the same thing.
	void BuildActions( ArchTool tool )
	{
		body.AddSeparator();

		var row = body.AddRow();

		row.AddStretchCell();
		row.Add( new Button.Danger( "Delete", "delete" ) { Clicked = () => tool.DeleteSelected() } );
	}
}