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