Editor/Porch/ArchPorchUi.cs

Editor UI for porch architecture parts. Builds the sidebar controls for placement, roof, included options, framing sizes and steps for an ArchPorchPart, and supplies an IArchSheet implementation that binds those controls to the selected part.

Reflection
using System;
using System.Linq;
using Editor;
using Sandbox;

namespace Sunless.Architecture;

// One sheet, read by the Porch subtool's draft AND by Select, off the same record - which is the whole
// reason a placed porch can now be given a roof at all. The roof used to be a flag on the subtool, so it
// could only ever be answered while the mouse was still down.
public static class ArchPorchUi
{
	// A tick that GATES the fields under it has to rebuild the sheet as well as commit, or the fields it
	// reveals never arrive and the ones it hides stay put.
	static Action Both( Action changed, Action refresh )
	{
		return () =>
		{
			changed?.Invoke();
			refresh?.Invoke();
		};
	}

	public static void Build( ToolSidebarWidget panel, ArchPorchPart porch, Action refresh, Action changed )
	{
		using ( var grid = ArchIconGrid.In( panel.AddGroup( "Standing" ) ) )
		{
			grid.Pick( "Against the house — the shell is subtracted, so a drag over a corner wraps it", "porch_against", "home",
				porch.Standing == PorchStanding.Against,
				() => { porch.Standing = PorchStanding.Against; Both( changed, refresh )(); } );

			grid.Pick( "Free-standing — the drag IS the deck, which is how one stands inside a room", "porch_free", "crop_free",
				porch.Standing == PorchStanding.Free,
				() => { porch.Standing = PorchStanding.Free; Both( changed, refresh )(); } );
		}

		ArchSidebarSection.Show( panel, "Porch.placement", "Placement", deck =>
		{
			deck.Add( ArchPartUi.Number( "Deck drop", porch.DeckDrop, 24f,
				value => porch.GradeHeight = porch.BaseHeight - value, changed ) );

			deck.Add( ArchPartUi.Number( "Head height", porch.HeadHeight, 100f, value => porch.HeadHeight = value, changed ) );
		} );

		ArchSidebarSection.Show( panel, "Porch.roof", "Roof", roof =>
		{
			roof.Add( ArchPartUi.Check( "Roof over it", porch.Roofed, value => porch.Roofed = value, Both( changed, refresh ) ) );

			if ( !porch.Roofed )
			{
				return;
			}

			roof.Add( ArchPartUi.Number( "Pitch", porch.Pitch, 0f, value => porch.Pitch = value, changed ) );
			roof.Add( ArchPartUi.Wrapped( "The section is re-cut from where the legs stand on every commit, and tucks under whatever eave hangs over it." ) );
		} );

		ArchSidebarSection.Show( panel, "Porch.include", "Include", group =>
		{
			using var grid = ArchIconGrid.In( group );

			grid.Toggle( "Deck under it — off leaves a canopy over open ground", "opt_porch_deck", "layers", porch.Deck,
				value => { porch.Deck = value; changed?.Invoke(); } );

			grid.Toggle( "Exposed rafters instead of a lined ceiling", "opt_rafters", "reorder", porch.Rafters,
				value => { porch.Rafters = value; changed?.Invoke(); } );

			grid.Toggle( "Raking struts back to the wall", "opt_braces", "call_made", porch.Braces,
				value => { porch.Braces = value; changed?.Invoke(); } );

			grid.Toggle( "Railing on the open edges", "opt_railing", "fence", porch.Railings,
				value => { porch.Railings = value; changed?.Invoke(); } );

			grid.Toggle( "Door through the wall it fronts — the way in off the deck", "opt_porch_door", "door_front", porch.Door,
				value => { porch.Door = value; changed?.Invoke(); } );
		} );
	}

	public static void Sizes( ToolSidebarWidget panel, ArchPorchPart porch, Action changed )
	{
		ArchSidebarSection.Disclosure( panel, "Porch.frame", "Frame", true, group =>
		{
			using ( var grid = ArchIconGrid.In( group ) )
			{
				grid.Toggle( "Plinth under the deck", "opt_plinth", "vertical_align_bottom", porch.Plinth,
					value => { porch.Plinth = value; changed?.Invoke(); } );

				grid.Toggle( "Posts on the open perimeter", "opt_posts", "view_column", porch.Posts,
					value => { porch.Posts = value; changed?.Invoke(); } );

				grid.Toggle( "Header beam over the posts", "opt_beam", "horizontal_rule", porch.Beam,
					value => { porch.Beam = value; changed?.Invoke(); } );
			}

			group.Add( ArchPartUi.Number( "Post spacing", porch.PostSpacing, 96f, value => porch.PostSpacing = value, changed ) );
		} );
	}

	// The placement draft has no plan to file a flight into, so the button only exists on a standing porch.
	// A flight is a REAL layer here - it gets a row, handles and a sheet - and the railing's gap is read back
	// off it, so adding a second way down needs nothing else said anywhere.
	public static void Steps( ToolSidebarWidget panel, ArchTool tool, ArchSelection picked, ArchPorchPart porch, Action refresh, Action changed )
	{
		ArchSidebarSection.Show( panel, "Porch.steps", "Steps", group =>
		{
			group.Add( ArchPartUi.Wrapped( porch.Stairs.Count == 0
				? "No way down. A flight added here becomes its own row — drag it to move it along the deck."
				: $"{porch.Stairs.Count} flight{(porch.Stairs.Count == 1 ? "" : "s")} off this deck, each its own row." ) );

			if ( porch.DeckDrop < 4f )
			{
				group.Add( ArchPartUi.Wrapped( "The deck stands too close to grade for a flight." ) );

				return;
			}

			group.Add( new Button( "Add a flight", "stairs" )
			{
				Clicked = () =>
				{
					var shape = ArchPorchShape.Resolve( porch, picked.Room, picked.Building, tool.Kit );

					if ( ArchPorchSteps.Stand( tool.Plan, porch, shape, tool.Kit ) is null )
					{
						Log.Info( "Architecture: that porch has no open edge left to bring a flight down through." );

						return;
					}

					changed?.Invoke();
					refresh?.Invoke();
				}
			} );
		} );
	}
}

public sealed class ArchPorchSheet : IArchSheet
{
	public Type Payload => typeof( ArchPorchPart );

	public void Build( ToolSidebarWidget panel, ArchTool tool, ArchSelection picked, Action refresh, Action changed )
	{
		if ( picked.Item is not ArchPorchPart porch )
		{
			return;
		}

		ArchPorchUi.Build( panel, porch, refresh, changed );
		ArchPorchUi.Sizes( panel, porch, changed );
		ArchPorchUi.Steps( panel, tool, picked, porch, refresh, changed );
	}
}