Editor/UI/ArchPresetBrowserDialog.cs

An editor UI dialog for browsing architecture presets. It displays categories, search, a grid of preset tiles with previews or glyphs, lets the user pick or design a preset, and watches preview updates to repaint.

Native Interop
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;

namespace Sunless.Architecture;

// The expanded half of the preset chooser, reached from every inline browser's header so browsing,
// comparing and designing take one route whatever the tool.
public sealed class ArchPresetBrowserDialog<T> : Dialog, IArchPreviewWatcher
{
	const string Everything = "All";
	const string Preferred = "Recommended";
	const float Strip = 34f;

	readonly ArchKit kit;
	readonly IReadOnlyList<ArchPresetItem<T>> items;
	readonly Func<T, bool> chosen;
	readonly Action<T> use;
	readonly LineEdit search;
	readonly ArchTileGrid grid;
	readonly Label counted;
	readonly List<Widget> cards = new();
	readonly List<Button> categories = new();

	string category = Preferred;
	ArchPresetItem<T> pending;

	public ArchPresetBrowserDialog(
		Widget parent,
		ArchKit kit,
		string title,
		IReadOnlyList<ArchPresetItem<T>> items,
		Func<T, bool> chosen,
		Action<T> use ) : base( parent )
	{
		this.kit = kit;
		this.items = items;
		this.chosen = chosen;
		this.use = use;

		pending = items.FirstOrDefault( item => chosen?.Invoke( item.Value ) == true );

		Window.Title = title;
		Window.SetWindowIcon( "grid_view" );
		Window.SetModal( true, true );
		Window.Size = new Vector2( 1000, 680 );
		Window.MinimumWidth = 780;
		Window.MinimumHeight = 520;

		Layout = Layout.Column();
		Layout.Margin = 0;
		Layout.Spacing = 0;

		var split = Layout.AddRow( 1 );

		split.Add( Navigation() );
		split.AddSeparator();

		var right = split.AddColumn( 1 );
		right.Margin = 10;
		right.Spacing = 8;

		var top = right.AddRow();
		top.Spacing = 8;

		search = new LineEdit( "" ) { PlaceholderText = $"Search {title.ToLowerInvariant()}" };
		search.TextEdited += _ => Fill();
		top.Add( search, 1 );

		counted = new Label( "" );
		counted.SetStyles( "font-size: 10px; color: rgba(255,255,255,0.4);" );
		top.Add( counted );

		grid = new ArchTileGrid( this, ArchTileSize.Browsed );
		right.Add( grid, 1 );

		Layout.AddSeparator();

		var footer = Layout.AddRow();
		footer.Margin = 10;
		footer.Spacing = 6;
		footer.AddStretchCell();

		DesignButton = new Button( "Design new preset", "tune" ) { Clicked = () => Design?.Invoke() };
		footer.Add( DesignButton );

		footer.Add( new Button( "Cancel", "close" ) { Clicked = Close } );
		footer.Add( new Button.Primary( "Use preset", "check" ) { Clicked = Confirm } );

		Fill();

		ArchPreviewPump.Watch( this );

		search.Focus();
	}

	public Button DesignButton { get; }

	public Action Design { get; set; }

	Widget Navigation()
	{
		var column = new Widget( this ) { Layout = Layout.Column() };
		column.Layout.Margin = 10;
		column.Layout.Spacing = 3;
		column.FixedWidth = 168;

		foreach ( var name in Names() )
		{
			var captured = name;
			var button = new Button( name ) { IsToggle = true, Clicked = () => Navigate( captured ) };

			categories.Add( button );
			column.Layout.Add( button );
		}

		column.Layout.AddStretchCell();

		Lit();

		return column;
	}

	List<string> Names()
	{
		var names = new List<string>();

		if ( items.Any( item => item.Recommended ) )
		{
			names.Add( Preferred );
		}

		names.Add( Everything );
		names.AddRange( items
			.Select( item => item.Category )
			.Where( name => !string.IsNullOrWhiteSpace( name ) )
			.Distinct()
			.OrderBy( name => name ) );

		category = names[0];

		return names;
	}

	void Navigate( string name )
	{
		category = name;

		Lit();
		Fill();
	}

	void Lit()
	{
		foreach ( var button in categories )
		{
			button.IsChecked = button.Text == category;
		}
	}

	void Fill()
	{
		var showing = items
			.Where( Included )
			.Where( item => item.Matches( search.Text ) )
			.OrderByDescending( item => item.Recommended )
			.ToList();

		counted.Text = $"{showing.Count} of {items.Count}";
		cards.Clear();

		var section = new ArchTileSection();

		foreach ( var item in showing )
		{
			var captured = item;

			section.Tiles.Add( canvas => Card( canvas, captured ) );
		}

		grid.Set( new[] { section } );
	}

	bool Included( ArchPresetItem<T> item )
	{
		return category switch
		{
			Everything => true,
			Preferred => item.Recommended,
			_ => item.Category == category
		};
	}

	Widget Card( Widget canvas, ArchPresetItem<T> item )
	{
		var tile = new ArchTile( canvas, null, item.Name, item.Detail, false, ArchTileSize.Browsed )
		{
			Badge = item.Badge,
			ToolTip = string.IsNullOrWhiteSpace( item.Detail ) ? item.Name : $"{item.Name} — {item.Detail}",
			Current = () => pending == item,
			Clicked = () => { pending = item; Repaint(); },
			Opened = Confirm
		};

		if ( item.Recipe is not null )
		{
			var art = new Vector2( ArchTileSize.Browsed.x, ArchTileSize.Browsed.y - Strip ) * 1.75f;

			tile.Source = () => ArchPresetPreview.For( item.Identity, item.Shot( art ), kit, item.Recipe );
		}
		else if ( !string.IsNullOrWhiteSpace( item.Glyph ) )
		{
			tile.Glyph = rect => Paint.DrawIcon( rect, item.Glyph, rect.Height );
		}

		cards.Add( tile );

		return tile;
	}

	void Repaint()
	{
		foreach ( var card in cards.Where( card => card.IsValid() ) )
		{
			card.Update();
		}
	}

	void Confirm()
	{
		if ( pending is null )
		{
			return;
		}

		use( pending.Value );
		Close();
	}

	public bool Alive => this.IsValid();

	public void PreviewTick( bool advanced )
	{
		if ( advanced )
		{
			Repaint();
		}
	}

	protected override void OnKeyPress( KeyEvent e )
	{
		if ( e.Key == KeyCode.Enter || e.Key == KeyCode.Return )
		{
			Confirm();
			e.Accepted = true;
			return;
		}

		base.OnKeyPress( e );
	}
}