Editor/UI/ArchPresetBrowser.cs

Editor UI for browsing authored architecture presets. Defines a generic preset item model (metadata, preview recipe) and a two-column, scrollable preset browser widget with search, resize grip, preview ticking, context menu actions (choose, remove, design, reload, restore) and an expandable dialog.

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

namespace Sunless.Architecture;

// What a browser shows about one authored preset. Recipe is what stages it; without one the card falls
// back to its glyph, so a kind with no preview yet still browses.
public sealed class ArchPresetItem<T>
{
	public T Value { get; init; }
	public string Name { get; init; }
	public string Detail { get; init; }
	public string Identity { get; init; }
	public string Category { get; init; }
	public string Badge { get; init; }
	public string Glyph { get; init; }
	public string Tags { get; init; }
	public bool Recommended { get; init; }
	public Angles View { get; init; } = ArchPresetPreview.Elevation;
	public ArchPreviewFocus Focus { get; init; } = ArchPreviewFocus.Unit;
	public BBox? Interest { get; init; }
	public Func<ArchStaged> Recipe { get; init; }

	public ArchPreviewShot Shot( Vector2 size ) => new( size, View, Focus, Interest );

	public bool Matches( string search )
	{
		if ( string.IsNullOrWhiteSpace( search ) )
		{
			return true;
		}

		var haystack = $"{Name} {Detail} {Category} {Tags}";

		return haystack.Contains( search, StringComparison.OrdinalIgnoreCase );
	}
}

// The inline half of the preset chooser: a bounded, visibly scrollable two-column grid whose header
// carries the count, the designer and the way into the expanded modal.
public sealed class ArchPresetBrowser<T> : Widget, IArchPreviewWatcher
{
	const int Searchable = 8;
	const float Shortest = 96f;
	const float Tallest = 460f;

	readonly ArchKit kit;
	readonly string stateKey;
	readonly string title;
	readonly Label count;
	readonly LineEdit search;
	readonly ArchTileGrid grid;
	readonly Widget summary;
	readonly Label summaryText;
	readonly List<Widget> cards = new();

	IReadOnlyList<ArchPresetItem<T>> items = Array.Empty<ArchPresetItem<T>>();
	Vector2 card = ArchTileSize.Card;
	bool collapsed;
	bool restored;

	public ArchPresetBrowser( Widget parent, ArchKit kit, string stateKey, string title ) : base( parent )
	{
		this.kit = kit;
		this.stateKey = stateKey;
		this.title = title;

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

		var header = Layout.AddRow();
		header.Spacing = 4;

		var caption = new Label( title.ToUpperInvariant() );
		caption.SetStyles( "font-size: 9px; font-weight: 700; color: rgba(255,255,255,0.55);" );
		header.Add( caption );

		count = new Label( "" );
		count.SetStyles( "font-size: 9px; color: rgba(255,255,255,0.3);" );
		header.Add( count );

		header.AddStretchCell();

		RestoreButton = Chip( "restore_from_trash" );
		RestoreButton.Visible = false;
		RestoreButton.OnClick = () => Restore?.Invoke();
		header.Add( RestoreButton );

		ReloadButton = Chip( "refresh" );
		ReloadButton.Visible = false;
		ReloadButton.ToolTip = $"Reload {title.ToLowerInvariant()} from disk";
		ReloadButton.OnClick = () => Reload?.Invoke();
		header.Add( ReloadButton );

		DesignButton = Chip( "design_services" );
		DesignButton.Visible = false;
		DesignButton.OnClick = () => Design?.Invoke();
		header.Add( DesignButton );

		// A grid, not diagonal arrows: the button opens a searchable wall of every preset in the kit, and
		// at this size an "expand" glyph reads as nothing at all.
		var expand = Chip( "grid_view" );
		expand.ToolTip = $"Browse all {title.ToLowerInvariant()}";
		expand.OnClick = Expand;
		header.Add( expand );

		search = new LineEdit( ArchSidebarState.Search( stateKey ) ) { PlaceholderText = "Search", Visible = false };
		search.TextEdited += text =>
		{
			ArchSidebarState.Searched( stateKey, text );
			Fill();
		};
		Layout.Add( search );

		grid = new ArchTileGrid( this, ArchTileSize.Card, 4f );
		grid.FixedHeight = ArchSidebarState.Height( stateKey, 224f );
		Layout.Add( grid );

		Layout.Add( new ArchBrowserGrip( height =>
		{
			grid.FixedHeight = Math.Clamp( height, Shortest, Tallest );
			ArchSidebarState.Resize( stateKey, grid.FixedHeight );
		}, () => grid.FixedHeight ) );

		summary = new Widget( this ) { Layout = Layout.Row(), Visible = false };
		summary.Layout.Spacing = 6;

		summaryText = new Label( "" );
		summaryText.SetStyles( "font-size: 10px; color: rgba(255,255,255,0.75);" );
		summary.Layout.Add( summaryText );
		summary.Layout.AddStretchCell();

		Layout.Add( summary );

		ArchPreviewPump.Watch( this );
	}

	static IconButton Chip( string icon )
	{
		return new IconButton( icon )
		{
			FixedSize = 28,
			IconSize = 19,
			Background = Theme.ControlBackground,
			BackgroundActive = Theme.Primary.WithAlpha( 0.25f ),
			Foreground = Theme.TextControl.WithAlpha( 0.8f )
		};
	}

	public IconButton DesignButton { get; }

	public IconButton ReloadButton { get; }

	// The way back from a removal, and it stands in the header rather than on a card: a grid emptied by removals
	// has no card left to right-click.
	public IconButton RestoreButton { get; }

	public Action<T> Choose { get; set; }

	public Func<T, bool> Chosen { get; set; }

	public Action Design { get; set; }

	// Authored on disk rather than in the kit, so the browser is where the author re-reads it.
	public Action Reload { get; set; }

	// Set only where a preset can be un-authored; without it a card carries no menu at all.
	public Action<ArchPresetItem<T>> Remove { get; set; }

	public Action Restore { get; set; }

	// A live placement run needs the placement values on screen more than it needs the whole kit.
	public bool Collapsed
	{
		get => collapsed;
		set
		{
			collapsed = value;

			grid.Visible = !value;
			search.Visible = !value && items.Count > Searchable;
			summary.Visible = value;

			if ( value )
			{
				summaryText.Text = items.FirstOrDefault( item => Chosen?.Invoke( item.Value ) == true )?.Name ?? "None";
			}
		}
	}

	public void Set( IReadOnlyList<ArchPresetItem<T>> entries )
	{
		items = entries ?? Array.Empty<ArchPresetItem<T>>();

		search.Visible = !collapsed && items.Count > Searchable;

		Fill();
	}

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

		count.Text = showing.Count == items.Count ? $"{items.Count}" : $"{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, card ) );
		}

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

	const int Columns = 2;

	Vector2 Sized()
	{
		// The canvas pads both sides and the scrollbar takes its own strip; what is left is two cards
		// and the gutter between them.
		var usable = Width - 8f - 10f;

		if ( usable < 96f )
		{
			return ArchTileSize.Card;
		}

		var span = MathF.Floor( (usable - 4f * (Columns - 1)) / Columns );

		return new Vector2( span, MathF.Floor( span * 0.84f ) + Strip );
	}

	protected override void OnResize()
	{
		base.OnResize();

		var wanted = Sized();

		if ( wanted == card )
		{
			return;
		}

		card = wanted;
		grid.Retile( card );

		Fill();
	}

	const float Strip = 26f;

	Widget Card( Widget canvas, ArchPresetItem<T> item, Vector2 size )
	{
		var tile = new ArchTile( canvas, null, item.Name, item.Detail, false, size )
		{
			Badge = item.Badge,
			LabelStrip = Strip,
			ToolTip = string.IsNullOrWhiteSpace( item.Detail ) ? item.Name : $"{item.Name} — {item.Detail}",
			Current = () => Chosen?.Invoke( item.Value ) == true,
			Clicked = () => Choose?.Invoke( item.Value )
		};

		if ( Remove is not null )
		{
			tile.Menu = menu => menu.AddOption( $"Remove {item.Name}", "delete", () => Remove( item ) );
		}

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

			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;
	}

	public bool Alive => this.IsValid();

	// One stage per frame keeps a browser filling in over a few frames instead of stalling the tool open.
	public void PreviewTick( bool advanced )
	{
		if ( !Visible || collapsed )
		{
			return;
		}

		Remember();

		if ( !advanced )
		{
			return;
		}

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

	// A rebuilt sidebar hands back a scrollbar with no range until it has laid out, so the restore waits
	// for one and everything after it is the author's own scrolling.
	void Remember()
	{
		var bar = grid.Scroller?.VerticalScrollbar;

		if ( !bar.IsValid() )
		{
			return;
		}

		if ( !restored )
		{
			if ( bar.Maximum <= 0 )
			{
				return;
			}

			bar.Value = Math.Min( (int)ArchSidebarState.Scroll( stateKey ), bar.Maximum );
			restored = true;

			return;
		}

		ArchSidebarState.Scrolled( stateKey, bar.Value );
	}

	void Expand()
	{
		var dialog = new ArchPresetBrowserDialog<T>( this, kit, title, items, Chosen, chosen =>
		{
			Choose?.Invoke( chosen );
			Fill();
		} );

		dialog.Design = Design;
		dialog.Show();
	}
}

// Dragging the browser/properties boundary, remembered per tool. A real splitter would have to own the
// sidebar's own scroll, which belongs to the engine's tool shelf.
public sealed class ArchBrowserGrip : Widget
{
	readonly Action<float> resized;
	readonly Func<float> current;

	float anchor;
	float start;
	bool dragging;

	public ArchBrowserGrip( Action<float> resized, Func<float> current )
	{
		this.resized = resized;
		this.current = current;

		FixedHeight = 7f;
		Cursor = CursorShape.SizeV;
	}

	protected override void OnMousePress( MouseEvent e )
	{
		if ( !e.LeftMouseButton )
		{
			return;
		}

		dragging = true;
		anchor = e.ScreenPosition.y;
		start = current();
	}

	protected override void OnMouseReleased( MouseEvent e )
	{
		dragging = false;
	}

	protected override void OnMouseMove( MouseEvent e )
	{
		if ( !dragging )
		{
			return;
		}

		resized( start + (e.ScreenPosition.y - anchor) );
	}

	protected override void OnPaint()
	{
		var bar = new Rect( LocalRect.Center.x - 14f, LocalRect.Center.y - 1f, 28f, 2f );

		Paint.ClearPen();
		Paint.SetBrush( Theme.Text.WithAlpha( Paint.HasMouseOver ? 0.35f : 0.15f ) );
		Paint.DrawRect( bar, 1 );
	}
}