Editor/Tool/Subtools/ArchPartUi.cs

Editor UI for architectural parts. Builds sidebar sheets and controls for different ArchPart types (building, opening, fence, downpipe, trim, room) and utility widgets for numeric/boolean inputs.

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

namespace Sunless.Architecture;

// One layout per kind, read by both creator and Select subtool.
public static class ArchPartUi
{
	public const float PanelWidth = 208f;

	public static void Sheet( ToolSidebarWidget panel, ArchTool tool, ArchSelection picked, Action refresh )
	{
		Action changed = () => tool.Touch( $"Edit {picked.Describe()}" );
		var sheet = ArchSheets.Load().For( picked.Item );

		if ( sheet is not null )
		{
			sheet.Build( panel, tool, picked, refresh, changed );

			return;
		}

		switch ( picked.Item )
		{
			case ArchBuilding building:
				Building( panel, building, changed );
				break;

			case ArchOpening opening:
				Opening( panel, opening, refresh, changed );
				break;

			case ArchFencePart fence:
				Fence( panel, fence, refresh, changed );
				break;

			case ArchDownpipePart pipe:
				Downpipe( panel, pipe, tool.Kit, changed );
				break;

			case ArchTrimPart trim:
				Trim( panel, trim, tool.Kit, changed );
				break;

			case ArchRoom room:
				Room( panel, room, changed );
				break;
		}
	}

	static void Building( ToolSidebarWidget panel, ArchBuilding building, Action changed )
	{
		ArchSidebarSection.Show( panel, "Building.shell", "Shell", group =>
		{
			var name = new LineEdit( building.Name );

			name.TextEdited += text =>
			{
				building.Name = text;
				changed?.Invoke();
			};

			group.Add( name );
		} );
	}

	static void Opening( ToolSidebarWidget panel, ArchOpening opening, Action refresh, Action changed )
	{
		ArchSidebarSection.Show( panel, "Opening.hole", "Hole", size =>
		{
			size.Add( Number( "Along the wall", opening.Offset, 0f, value => opening.Offset = value, changed ) );
			size.Add( Number( "Width", opening.Width, 0f, value => opening.Width = value, changed ) );
			size.Add( Number( "Start height", opening.SillHeight, 0f, value => SetOpeningStart( opening, value ), changed ) );
			size.Add( Number( "End height", opening.Top, 0f, value => SetOpeningEnd( opening, value ), changed ) );
		} );

		// Only what this kind actually generates - controls the generator never reads would do nothing.
		ArchSidebarSection.Show( panel, "Opening.unit", "Unit", group =>
		{
			using var grid = ArchIconGrid.In( group );

			grid.Toggle( "Casing round the opening", "opt_cased", "filter_frames", opening.Cased,
				value => { opening.Cased = value; changed?.Invoke(); } );

			if ( opening.Kind.Sills() )
			{
				grid.Toggle( "Sill board under it", "opt_sill", "border_bottom", opening.HasSill,
					value => { opening.HasSill = value; changed?.Invoke(); } );
			}

			if ( opening.Kind.Hangs() )
			{
				grid.Toggle( "Hang a leaf in it", "opt_leaf", "door_front", opening.Leaf,
					value => { opening.Leaf = value; refresh?.Invoke(); changed?.Invoke(); } );
			}

			if ( opening.IsHung )
			{
				grid.Toggle( "Hinge on the other side", "opt_flip_hinge", "flip", opening.FlipHinge,
					value => { opening.FlipHinge = value; changed?.Invoke(); } );

				grid.Toggle( "Standing open when the level starts", "opt_start_open", "door_open", opening.StartOpen,
					value => { opening.StartOpen = value; changed?.Invoke(); } );
			}

			if ( opening.Kind.Glazes() )
			{
				grid.Toggle( "Glazed — sashes and panes in the hole", "opt_glazed", "window", opening.Glazed,
					value => { opening.Glazed = value; refresh?.Invoke(); changed?.Invoke(); } );
			}

			ArchOpeningUi.Furniture( grid, opening.Kind, opening.Furniture,
				value => { opening.Furniture = value; changed?.Invoke(); } );
		} );

		if ( !opening.IsGlazed )
		{
			return;
		}

		// The sash arrangement is authored on the preset and rarely re-cut on one standing unit, so it folds.
		ArchSidebarSection.Disclosure( panel, "Opening.glazing", "Glazing", true, glazing =>
		{
			using ( var grid = ArchIconGrid.In( glazing ) )
			{
				grid.Toggle( "Fit mullions and sashes to the resized frame", "opt_window_auto_layout", "auto_awesome", opening.AutoLayout,
					value => { opening.AutoLayout = value; changed?.Invoke(); } );

				grid.Toggle( "Already broken", "opt_broken", "broken_image", opening.Glass == GlassState.Broken,
					value => { opening.Glass = value ? GlassState.Broken : GlassState.Intact; changed?.Invoke(); } );

				grid.Toggle( "Breakable — panes on their own object", "opt_breakable", "shield", opening.Breakable,
					value => { opening.Breakable = value; changed?.Invoke(); } );
			}

			glazing.Add( Integer( "Lights", opening.Lights, 1,
				value => { opening.Lights = value; opening.AutoLayout = false; }, changed ) );
			glazing.Add( Integer( "Sashes", opening.Sashes, 2,
				value => { opening.Sashes = value; opening.AutoLayout = false; }, changed ) );
			glazing.Add( Integer( "Pane columns", opening.PanesAcross, 2, value => opening.PanesAcross = value, changed ) );
			glazing.Add( Integer( "Pane rows", opening.PanesDown, 3, value => opening.PanesDown = value, changed ) );
		} );
	}

	static void SetOpeningStart( ArchOpening opening, float value )
	{
		var top = opening.Top;
		opening.SillHeight = MathF.Min( value, top - ArchGridService.FinestSize );
		opening.Height = MathF.Max( ArchGridService.FinestSize, top - opening.SillHeight );
	}

	static void SetOpeningEnd( ArchOpening opening, float value )
	{
		opening.Height = MathF.Max( ArchGridService.FinestSize, value - opening.SillHeight );
	}

	static void Fence( ToolSidebarWidget panel, ArchFencePart fence, Action refresh, Action changed )
	{
		ArchSidebarSection.Show( panel, "Fence.run", "Run", group =>
		{
			using var grid = ArchIconGrid.In( group );

			grid.Toggle( "Closed loop", "opt_closed", "crop_square", fence.Closed,
				value => { fence.Closed = value; changed?.Invoke(); } );
		} );

		ArchBarrierUi.Build( panel, fence.Barrier, refresh, changed );
	}

	public static void Downpipe( ToolSidebarWidget panel, ArchDownpipePart pipe, ArchKit kit, Action changed )
	{
		ArchSidebarSection.Show( panel, "Downpipe.section", "Section", section =>
		{
			using ( var kinds = ArchIconGrid.In( section ) )
			{
				kinds.Pick( "Rainwater — taps the gutter and swans over to the wall", "pipe_rainwater", "water_drop",
					pipe.Run == PipeRun.Rainwater, () => { pipe.Run = PipeRun.Rainwater; changed?.Invoke(); } );

				kinds.Pick( "Conduit — a bracketed service run straight down the elevation", "pipe_conduit", "power_input",
					pipe.Run == PipeRun.Conduit, () => { pipe.Run = PipeRun.Conduit; changed?.Invoke(); } );
			}

			using ( var grid = ArchIconGrid.In( section ) )
			{
				foreach ( var entry in kit.Profiles.Where( entry => entry.Name.Contains( "pipe", StringComparison.OrdinalIgnoreCase ) ) )
				{
					var name = entry.Name;

					grid.Pick( name, ArchIcons.ProfileSlug( name ), ArchIcons.ProfileGlyph, pipe.Profile == name,
						() => { pipe.Profile = name; changed?.Invoke(); } );
				}
			}

			using var fittings = ArchIconGrid.In( section );

			fittings.Toggle( "Shoe at the bottom", "opt_shoe", "south", pipe.Shoe,
				value => { pipe.Shoe = value; changed?.Invoke(); } );
		} );
	}

	public static void Trim( ToolSidebarWidget panel, ArchTrimPart trim, ArchKit kit, Action changed )
	{
		ArchSidebarSection.Show( panel, "Trim.profile", "Profile", group =>
		{
			using ( var grid = ArchIconGrid.In( group ) )
			{
				foreach ( var entry in kit.Profiles )
				{
					var name = entry.Name;

					grid.Pick( name, ArchIcons.ProfileSlug( name ), ArchIcons.ProfileGlyph, trim.Profile == name,
						() => { trim.Profile = name; changed?.Invoke(); } );
				}
			}

			if ( !trim.Follows )
			{
				return;
			}

			using var reach = ArchIconGrid.In( group );

			reach.Pick( "Floor edge only", "trim_floor", "horizontal_rule", trim.Reach == ArchTrimReach.Floor,
				() => { trim.Reach = ArchTrimReach.Floor; changed?.Invoke(); } );
			reach.Pick( "Wall jambs only", "trim_walls", "vertical_align_center", trim.Reach == ArchTrimReach.Walls,
				() => { trim.Reach = ArchTrimReach.Walls; changed?.Invoke(); } );
			reach.Pick( "One continuous run over the floor edge and wall jambs", "trim_both", "polyline", trim.Reach == ArchTrimReach.Both,
				() => { trim.Reach = ArchTrimReach.Both; changed?.Invoke(); } );
		} );

		ArchSidebarSection.Disclosure( panel, "Trim.role", "Material role", true, group =>
		{
			using var roles = ArchIconGrid.In( group );

			foreach ( var value in new[] { ArchSurface.Trim, ArchSurface.Gutter, ArchSurface.Baseboard, ArchSurface.WallCap, ArchSurface.Pillar } )
			{
				var captured = value;

				roles.Pick( captured.ToString(), $"surface_{captured}".ToLowerInvariant(), TrimRoleGlyph( captured ),
					trim.Surface == captured, () => { trim.Surface = captured; changed?.Invoke(); } );
			}
		} );
	}

	// A room has no tool of its own - a shell stands one and a wall sections one - so this sheet is the only
	// place its own numbers live, and everything it had stays reachable here.
	static void Room( ToolSidebarWidget panel, ArchRoom room, Action changed )
	{
		ArchSidebarSection.Show( panel, "Room.include", "Include", group =>
		{
			using var grid = ArchIconGrid.In( group );

			grid.Toggle( "Own floor slab", "opt_own_floor", "layers", room.HasFloor,
				value => { room.HasFloor = value; changed?.Invoke(); } );

			grid.Toggle( "Ceiling body under the plate", "opt_ceiling", "border_top", room.HasCeiling,
				value => { room.HasCeiling = value; changed?.Invoke(); } );

			grid.Toggle( "Boards over the slab", "opt_floor_boards", "view_stream", room.FloorBoards,
				value => { room.FloorBoards = value; changed?.Invoke(); } );

			grid.Toggle( "Stands on a raised foundation", "opt_raised", "foundation", room.RaisedFoundation,
				value => { room.RaisedFoundation = value; changed?.Invoke(); } );
		} );

		ArchSidebarSection.Show( panel, "Room.storey", "Storey", group =>
		{
			group.Add( Number( "Wall height", room.WallHeight, 0f, value => room.WallHeight = value, changed ) );
			group.Add( Number( "Board angle", room.FloorBoardYaw, 0f, value => room.FloorBoardYaw = value, changed ) );

			if ( room.HasCeiling )
			{
				group.Add( Number( "Ceiling depth", room.CeilingDepth, 0f, value => room.CeilingDepth = value, changed ) );
			}
		} );
	}

	static string TrimRoleGlyph( ArchSurface value ) => value switch
	{
		ArchSurface.Gutter => "water_damage",
		ArchSurface.Baseboard => "border_bottom",
		ArchSurface.WallCap => "border_top",
		ArchSurface.Pillar => "view_column",
		_ => "timeline"
	};

	// Value lands per keystroke; `changed` only applies - it debounces.
	public static Widget Number( string label, float value, float placeholder, Action<float> assign, Action changed = null )
	{
		return Pull( label, value != 0f ? value.ToString( "0.##" ) : "", placeholder != 0f ? placeholder.ToString( "0.##" ) : "0",
			ArchGridService.FinestSize, 1f, "0.##",
			text => { assign( float.TryParse( text, out var parsed ) ? parsed : 0f ); changed?.Invoke(); } );
	}

	// Whole numbers only - a count that jumps several at a flick can't be aimed.
	public static Widget Integer( string label, int value, int placeholder, Action<int> assign, Action changed = null )
	{
		return Pull( label, value != 0 ? value.ToString() : "", placeholder != 0 ? placeholder.ToString() : "0",
			1f, 0.25f, "0",
			text => { assign( int.TryParse( text, out var parsed ) ? parsed : 0 ); changed?.Invoke(); } );
	}

	public static Widget OptionalNumber( string label, float? value, float placeholder, Action<float?> assign, Action changed = null )
	{
		return Pull( label, value?.ToString( "0.##" ) ?? "", placeholder.ToString( "0.##" ),
			ArchGridService.FinestSize, 1f, "0.##",
			text => { assign( float.TryParse( text, out var parsed ) ? parsed : null ); changed?.Invoke(); } );
	}

	static Widget Pull( string label, string text, string placeholder, float snap, float perPixel, string format, Action<string> edited )
	{
		var holder = new Widget( null );
		holder.Layout = Layout.Row();
		holder.Layout.Spacing = 4;

		var name = new Label( label );
		name.MinimumWidth = 92;
		holder.Layout.Add( name );

		var edit = new ArchNumberEdit( text, snap, perPixel, format, value => edited( value.ToString( format ) ) )
		{
			PlaceholderText = placeholder
		};

		edit.TextEdited += edited;

		holder.Layout.Add( edit );

		return holder;
	}

	public static Widget Check( string label, bool value, Action<bool> assign, Action changed = null )
	{
		var box = new Checkbox( label ) { Value = value };

		box.Toggled += () =>
		{
			assign( box.Value );
			changed?.Invoke();
		};

		return box;
	}

	// Qt won't size wrappable labels; estimate and pin the line count.
	public static Widget Wrapped( string text )
	{
		var label = new Label( text );
		label.WordWrap = true;
		label.FixedWidth = PanelWidth;
		label.MinimumHeight = MathF.Ceiling( text.Length / 32f ) * 16f;

		return label;
	}
}