Editor/UI/ArchSidebarChrome.cs

Editor UI code for the architecture creation sidebar. It defines header, advice label, collapsible sections, a persistent run bar, and related widgets used to layout tool sidebars and stateful disclosures.

Native Interop
using System;
using Editor;
using Sandbox;

namespace Sunless.Architecture;

// The stable landmarks every creation sidebar opens with: what tool this is, what gesture it wants, and
// the sections that come and go beneath them.
public static class ArchSidebarLayout
{
	public static void Header( ToolSidebarWidget panel, string title, string icon, string shortcut, string advice )
	{
		panel.Layout.Add( new ArchSidebarHeader( title, icon, shortcut ) );

		if ( string.IsNullOrWhiteSpace( advice ) )
		{
			return;
		}

		panel.Layout.Add( Advice( advice ) );
	}

	// The shared *Ui forms lay themselves out through AddGroup, which needs a sidebar rather than a layout, so a
	// disclosure holds one by nesting it. Here once, or every caller folding a form away repeats the dance.
	public static void Form( Layout group, Action<ToolSidebarWidget> build )
	{
		var form = new ToolSidebarWidget();

		build( form );

		group.Add( form );
	}

	// Qt puts a wrappable label's minimum at one line however much text it holds, so the line count is estimated.
	public static Widget Advice( string text )
	{
		var label = new Label( text );

		label.WordWrap = true;
		label.FixedWidth = ArchPartUi.PanelWidth;
		label.MinimumHeight = MathF.Ceiling( text.Length / 36f ) * 14f + 4f;
		label.SetStyles( "font-size: 10px; color: rgba(255,255,255,0.48);" );

		return label;
	}
}

public sealed class ArchSidebarHeader : Widget
{
	readonly string title;
	readonly string icon;
	readonly string shortcut;

	public ArchSidebarHeader( string title, string icon, string shortcut )
	{
		this.title = title;
		this.icon = icon;
		this.shortcut = shortcut;

		FixedHeight = 36f;
	}

	protected override void OnPaint()
	{
		Paint.Antialiasing = true;
		Paint.TextAntialiasing = true;

		var body = LocalRect;

		Paint.SetPen( Theme.Blue );
		Paint.DrawIcon( new Rect( body.Left, body.Top + 9f, 18f, 18f ), icon, 18f );

		Paint.SetDefaultFont( 10f, 600 );
		Paint.SetPen( Theme.Text );
		Paint.DrawText( body.Shrink( 25f, 0f, 0f, 0f ), title, TextFlag.LeftCenter );

		if ( !string.IsNullOrWhiteSpace( shortcut ) )
		{
			Paint.SetDefaultFont( 7f, 500 );
			Paint.SetPen( Theme.TextControl.WithAlpha( 0.4f ) );
			Paint.DrawText( body, shortcut, TextFlag.RightCenter );
		}

		Paint.ClearBrush();
		Paint.SetPen( Theme.Text.WithAlpha( 0.09f ) );
		Paint.DrawLine( new Vector2( body.Left, body.Bottom - 1f ), new Vector2( body.Right, body.Bottom - 1f ) );
	}
}

// A disclosure that keeps its own open state per subtool, and that is simply absent when the choice above
// it cannot use it - a greyed control still asks the reader to work out why it cannot be used.
public sealed class ArchSidebarSection : Widget
{
	public static void Show( ToolSidebarWidget panel, string key, string title, Action<Layout> build )
	{
		Show( panel, key, title, true, true, false, build );
	}

	public static void Show( ToolSidebarWidget panel, string key, string title, bool visible, Action<Layout> build )
	{
		Show( panel, key, title, visible, true, false, build );
	}

	public static void Show( ToolSidebarWidget panel, string key, string title, bool visible, bool expanded, bool collapsible, Action<Layout> build )
	{
		if ( !visible )
		{
			return;
		}

		var section = new ArchSidebarSection( key, title, expanded, collapsible );

		build( section.content.Layout );

		panel.Layout.Add( section );
	}

	public static void Disclosure( ToolSidebarWidget panel, string key, string title, bool visible, Action<Layout> build )
	{
		Show( panel, key, title, visible, false, true, build );
	}

	readonly string key;
	readonly string title;
	readonly bool collapsible;
	readonly Widget content;

	bool open;
	bool hovered;

	ArchSidebarSection( string key, string title, bool expanded, bool collapsible )
	{
		this.key = key;
		this.title = title;
		this.collapsible = collapsible;

		open = !collapsible || ArchSidebarState.Expanded( key, expanded );

		Layout = Layout.Column();
		Layout.Spacing = 2;

		content = new Widget( this ) { Layout = Layout.Column() };
		content.Layout.Spacing = 3;
		content.Visible = open;

		Layout.Add( content );

		Margins();

		HorizontalSizeMode = SizeMode.Expand | SizeMode.CanGrow;
		VerticalSizeMode = SizeMode.CanShrink;
		MouseTracking = true;
	}

	// A zero hint takes the height from the content's minimum, which is what keeps a collapsed section thin.
	protected override Vector2 SizeHint() => 0;

	void Margins()
	{
		Layout.Margin = new Sandbox.UI.Margin( 6, 15, 6, open ? 6 : 0 );
	}

	void Toggle()
	{
		open = !open;
		content.Visible = open;

		Margins();
		ArchSidebarState.Expand( key, open );
		Update();
	}

	protected override void OnMouseMove( MouseEvent e )
	{
		var was = hovered;
		hovered = collapsible && e.LocalPosition.y < 14f;

		if ( was != hovered )
		{
			Update();
		}
	}

	protected override void OnMouseLeave()
	{
		if ( !hovered )
		{
			return;
		}

		hovered = false;
		Update();
	}

	protected override void OnMouseClick( MouseEvent e )
	{
		if ( !collapsible || e.LocalPosition.y > 14f )
		{
			return;
		}

		Toggle();
		e.Accepted = true;
	}

	protected override void OnPaint()
	{
		Paint.Antialiasing = true;
		Paint.TextAntialiasing = true;

		if ( open )
		{
			var frame = Paint.LocalRect;
			frame.Top += 6f;
			frame = frame.Shrink( 0, 0, 1, 1 );

			Paint.SetBrushAndPen( Theme.Text.WithAlpha( 0.012f ), Theme.Text.WithAlpha( 0.09f ) );
			Paint.DrawRect( frame, 4 );
		}

		Paint.SetPen( Theme.TextControl.WithAlpha( hovered ? 1f : 0.55f ) );
		Paint.SetDefaultFont( 7f, 600 );

		if ( collapsible )
		{
			Paint.DrawIcon( new Rect( 1, -2, 12, 14 ), open ? "expand_more" : "chevron_right", 10 );
			Paint.DrawText( new Vector2( 15, 0 ), title.ToUpperInvariant() );
			Cursor = CursorShape.Finger;
		}
		else
		{
			Paint.DrawText( new Vector2( 10, 0 ), title.ToUpperInvariant() );
		}
	}
}

public readonly record struct ArchRunState( bool Active, string Progress );

// Finish and Cancel exist only while a chained operation is live. It rides the tool FOOTER, outside the
// sidebar's scroll, so a long form cannot push the way out of a run off the bottom.
public sealed class ArchRunBar : Widget
{
	readonly Func<ArchRunState> read;
	readonly Label progress;

	public ArchRunBar( Func<ArchRunState> read, Action finish, Action cancel )
	{
		this.read = read;

		Layout = Layout.Row();
		Layout.Margin = new Sandbox.UI.Margin( 8, 6, 8, 6 );
		Layout.Spacing = 6;

		progress = new Label( "" );
		progress.SetStyles( "font-size: 10px; color: rgba(255,255,255,0.7);" );

		Layout.Add( progress );
		Layout.AddStretchCell();
		Layout.Add( new Button( "Cancel", "close" ) { Clicked = () => cancel?.Invoke() } );
		Layout.Add( new Button.Primary( "Finish", "check" ) { Clicked = () => finish?.Invoke() } );

		Visible = false;
	}

	[EditorEvent.Frame]
	public void Poll()
	{
		var state = read();

		if ( Visible != state.Active )
		{
			Visible = state.Active;
		}

		if ( !state.Active )
		{
			return;
		}

		var text = state.Progress ?? "";

		if ( progress.Text != text )
		{
			progress.Text = text;
		}
	}

	protected override void OnPaint()
	{
		Paint.ClearPen();
		Paint.SetBrush( Theme.TabBackground );
		Paint.DrawRect( LocalRect );

		Paint.SetPen( Theme.Blue.WithAlpha( 0.5f ) );
		Paint.DrawLine( new Vector2( 0, 0 ), new Vector2( Width, 0 ) );
	}
}