Editor/UI/ArchPresetBrowser.cs
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, IArchHeaded {
const int Searchable = 8;
const float Shortest = 96f;
const float Tallest = 460f;
readonly ArchKit kit;
readonly string stateKey;
readonly string title;
readonly Label caption;
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;
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 );
}
public void DropHeading() => caption.Visible = false;
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; }
// A preset with a Recipe can already be staged, which is all an export needs - so every shelf that names a
// kind offers one, and no subtool writes the gesture itself.
public string Exports { 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; }
// What the removal is CALLED. A preset core ships is hidden rather than deleted, and a menu saying Remove for
// one and Hide for the next is the only warning the author gets about which of the two they are about to do.
public Func<ArchPresetItem<T>, string> Removes { get; set; }
// Anything else a card offers, added above the removal. Kept as a hook rather than a list of properties
// because what a preset can do with itself is the browser's business least of all.
public Action<ArchPresetItem<T>, Menu> Options { 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 )
};
var exports = Exportable( item );
if ( Remove is not null || Options is not null || exports ) {
tile.Menu = menu => {
Options?.Invoke( item, menu );
if ( exports ) {
menu.AddOption( $"Export {item.Name} to OBJ…", "download", () => Export( item ) );
}
if ( Remove is not null ) {
menu.AddOption( Removes?.Invoke( item ) ?? $"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 );
}
public bool Exportable( ArchPresetItem<T> item ) {
return item?.Recipe is not null && !string.IsNullOrWhiteSpace( Exports );
}
// Parented to the BROWSER rather than to whatever opened the menu: a context menu is gone the moment it is
// clicked, and a dialog hung off one dies with it.
public void Export( ArchPresetItem<T> item ) {
new ArchExportDialog( this, item.Name,
settings => ArchExport.Write( Exports, item.Name, item.Recipe, kit, settings ) ).Show();
}
void Expand() {
var dialog = new ArchPresetBrowserDialog<T>( this, kit, title, items, Chosen, chosen => {
Choose?.Invoke( chosen );
Fill();
} );
dialog.Design = Design;
dialog.Remove = Remove is null ? null : item => { Remove( item ); dialog.Close(); };
dialog.Removes = Removes;
dialog.Options = Options;
dialog.Export = string.IsNullOrWhiteSpace( Exports ) ? null : (Action<ArchPresetItem<T>>)Export;
dialog.Refill();
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 );
}
}