UI editor code for an arch tile browser. ArchThumb loads tile thumbnail images from project or an index path and caches them. ArchTile is a clickable UI widget that draws a tile image, badges, glyphs and selection/hover states. ArchTileGrid lays out sections of tiles into a responsive grid and manages scrolling.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Editor;
using Sandbox;
namespace Sunless.Architecture;
public static class ArchThumb
{
const string IndexTiles = "E:/GameDev/_LIT/mega-scans/_index/tiles";
static readonly Dictionary<string, Pixmap> loaded = new();
static readonly HashSet<string> absent = new();
public static Pixmap For( string category, string name, string id )
{
var key = $"{category}|{name}|{id}";
if ( loaded.TryGetValue( key, out var hit ) )
{
return hit;
}
if ( absent.Contains( key ) )
{
return null;
}
var pixmap = Load( StagedColour( category, name ) ) ?? Load( IndexTile( id ) );
if ( pixmap is null )
{
absent.Add( key );
return null;
}
loaded[key] = pixmap;
return pixmap;
}
public static void Forget()
{
loaded.Clear();
absent.Clear();
}
static string StagedColour( string category, string name )
{
if ( string.IsNullOrWhiteSpace( category ) || string.IsNullOrWhiteSpace( name ) )
{
return null;
}
var root = Project.Current?.GetRootPath();
if ( string.IsNullOrWhiteSpace( root ) )
{
return null;
}
return Path.Combine( root, "Assets", "environment", "seamless", category, name, $"{name}_color.png" );
}
static string IndexTile( string id )
{
return string.IsNullOrWhiteSpace( id ) ? null : Path.Combine( IndexTiles, $"{id}.jpg" );
}
static Pixmap Load( string path )
{
if ( string.IsNullOrWhiteSpace( path ) || !File.Exists( path ) )
{
return null;
}
try
{
return Pixmap.FromFile( path );
}
catch ( Exception )
{
return null;
}
}
}
public sealed class ArchTile : Widget
{
public Action Clicked { get; set; }
public Action Opened { get; set; }
public Func<bool> Current { get; set; }
// Art that is generated on demand arrives frames after the tile does, so it is pulled rather than passed.
public Func<Pixmap> Source { get; set; }
// What the tile itself can do, on its own right-click - never a chip acting on whatever happens to be chosen.
public Action<ContextMenu> Menu { get; set; }
public string Badge { get; set; }
public Action<Rect> Glyph { get; set; }
public float Dim { get; set; } = 1f;
public float LabelStrip { get; set; } = 34f;
// Washed over the art: a blockout role is the same grid, told apart by hue.
public Color? Fill { get; set; }
readonly Pixmap image;
readonly string title;
readonly string detail;
readonly bool warn;
public ArchTile( Widget parent, Pixmap image, string title, string detail, bool warn, Vector2 size ) : base( parent )
{
this.image = image;
this.title = title ?? "";
this.detail = detail ?? "";
this.warn = warn;
Cursor = CursorShape.Finger;
FixedSize = size;
}
// A hotload cannot always re-map a captured lambda, and the orphan it leaves throws on every paint
// until the panel that made it is rebuilt. Dropping it once turns a permanent exception storm into a
// placeholder that the next sidebar rebuild fills back in.
Pixmap Art()
{
if ( Source is null )
{
return null;
}
try
{
return Source.Invoke();
}
catch ( NotImplementedException )
{
Source = null;
return null;
}
}
bool Selected()
{
if ( Current is null )
{
return false;
}
try
{
return Current();
}
catch ( NotImplementedException )
{
Current = null;
return false;
}
}
protected override void OnPaint()
{
var selected = Selected();
var hover = Paint.HasMouseOver;
var body = LocalRect;
var art = body.Shrink( 0, 0, 0, LabelStrip );
var picture = Art() ?? image;
Paint.ClearPen();
Paint.SetBrush( Theme.ControlBackground );
Paint.DrawRect( body, 3 );
if ( picture is not null )
{
Paint.Draw( art, picture, (hover || selected ? 1.0f : 0.88f) * Dim );
}
else if ( Fill is null )
{
Paint.SetBrush( Color.FromRgb( 0x2A2A2E ) );
Paint.DrawRect( art, 3 );
Paint.SetPen( Theme.TextControl.WithAlpha( 0.35f ) );
Paint.DrawIcon( art, Source is null ? "broken_image" : "hourglass_empty", 22 );
}
if ( Fill is { } wash )
{
Paint.ClearPen();
Paint.SetBrush( wash.WithAlpha( picture is null ? 1f : 0.62f ) );
Paint.DrawRect( art, 3 );
}
if ( Glyph is not null )
{
var side = Math.Max( 22f, art.Width * 0.16f );
var badge = new Rect( art.Right - side - 6, art.Top + 6, side, side );
Paint.ClearPen();
Paint.SetBrush( Color.Black.WithAlpha( 0.62f ) );
Paint.DrawRect( badge, 4 );
Glyph( badge.Shrink( side * 0.16f ) );
}
if ( !string.IsNullOrWhiteSpace( Badge ) )
{
var text = Badge.ToUpperInvariant();
var width = Math.Min( art.Width * 0.72f, text.Length * 5.9f + 12f );
var chip = new Rect( art.Left + 6, art.Top + 6, width, 18 );
Paint.ClearPen();
Paint.SetBrush( Theme.Primary.WithAlpha( 0.94f ) );
Paint.DrawRect( chip, 3 );
Paint.SetDefaultFont( 7.5f, 700 );
Paint.SetPen( Color.White );
Paint.DrawText( chip, text );
}
var label = body.Shrink( 6, 0, 6, 0 );
label.Top = art.Bottom + 2;
Paint.SetDefaultFont( 7.5f, 600 );
Paint.SetPen( warn ? Theme.Yellow : Color.White );
Paint.DrawText( label, title, TextFlag.LeftTop );
Paint.SetDefaultFont( 7f );
Paint.SetPen( Theme.TextControl.WithAlpha( 0.6f ) );
Paint.DrawText( label.Shrink( 0, 14, 0, 0 ), detail, TextFlag.LeftTop );
// An outline and a small mark, never a wash - the object has to stay readable while it is chosen.
if ( selected )
{
Paint.ClearBrush();
Paint.SetPen( Theme.Primary, 2 );
Paint.DrawRect( body.Shrink( 1 ), 3 );
var mark = new Rect( art.Right - 20f, art.Bottom - 20f, 16f, 16f );
Paint.ClearPen();
Paint.SetBrush( Theme.Primary );
Paint.DrawRect( mark, 8 );
Paint.SetPen( Color.White );
Paint.DrawIcon( mark, "check", 11 );
}
else if ( hover )
{
Paint.ClearBrush();
Paint.SetPen( Color.White.WithAlpha( 0.35f ), 1 );
Paint.DrawRect( body.Shrink( 0.5f ), 3 );
}
}
protected override void OnMouseClick( MouseEvent e )
{
if ( e.LeftMouseButton )
{
Clicked?.Invoke();
}
}
protected override void OnDoubleClick( MouseEvent e )
{
if ( !e.LeftMouseButton )
{
return;
}
Clicked?.Invoke();
Opened?.Invoke();
}
protected override void OnContextMenu( ContextMenuEvent e )
{
if ( Menu is null )
{
return;
}
var menu = new ContextMenu( this );
Menu( menu );
menu.OpenAtCursor( false );
e.Accepted = true;
}
protected override void OnMouseEnter()
{
Update();
}
protected override void OnMouseLeave()
{
Update();
}
}
public static class ArchTileSize
{
public static readonly Vector2 Preview = new( 276, 320 );
public static readonly Vector2 Role = new( 268, 300 );
// Two columns inside a 240px tool sidebar, and four or more inside the expanded modal.
public static readonly Vector2 Card = new( 86, 104 );
public static readonly Vector2 Browsed = new( 196, 224 );
}
public sealed class ArchTileSection
{
public string Title { get; set; }
public string Note { get; set; }
public List<Func<Widget, Widget>> Tiles { get; } = new();
}
public sealed class ArchTileGrid : Widget
{
sealed class Placed
{
public List<Widget> Headers { get; init; }
public List<Widget> Tiles { get; init; }
}
readonly ScrollArea scroll;
readonly Widget canvas;
readonly float padding;
readonly List<Placed> placed = new();
Vector2 tileSize;
int columns;
public ArchTileGrid( Widget parent, Vector2 tileSize, float padding = 8f ) : base( parent )
{
this.tileSize = tileSize;
this.padding = padding;
Layout = Layout.Column();
scroll = new ScrollArea( this );
scroll.HorizontalScrollbarMode = ScrollbarMode.Off;
canvas = new Widget( scroll ) { Layout = Layout.Column() };
// Extra at the foot: the last row scrolls flush against the edge and its caption is clipped by it.
canvas.Layout.Margin = new Sandbox.UI.Margin( padding, padding, padding, padding + 10f );
canvas.Layout.Spacing = padding * 0.75f;
scroll.Canvas = canvas;
Layout.Add( scroll );
}
public Vector2 TileSize => tileSize;
public ScrollArea Scroller => scroll;
// A sidebar browser divides its width into a fixed column count rather than fitting a fixed tile into
// it, so the cards reach both edges instead of leaving a column's worth of dead space on the right.
public void Retile( Vector2 size )
{
tileSize = size;
}
public void Set( IEnumerable<ArchTileSection> sections )
{
placed.Clear();
canvas.Layout.Clear( true );
foreach ( var section in sections )
{
var headers = new List<Widget>();
if ( !string.IsNullOrWhiteSpace( section.Title ) )
{
var caption = new Label( section.Title, canvas );
caption.SetStyles( "font-size: 11px; font-weight: 700; color: rgba(255,255,255,0.85); margin-top: 8px;" );
headers.Add( caption );
if ( !string.IsNullOrWhiteSpace( section.Note ) )
{
var note = new Label( section.Note, canvas );
note.SetStyles( "font-size: 10px; color: rgba(255,255,255,0.4); margin-bottom: 2px;" );
note.WordWrap = true;
headers.Add( note );
}
}
placed.Add( new Placed
{
Headers = headers,
Tiles = section.Tiles.Select( builder => builder( canvas ) ).ToList()
} );
}
Arrange();
}
// Clear(false) re-adds the SAME widgets; fresh Labels would linger on the canvas at stale positions.
void Arrange()
{
canvas.Layout.Clear( false );
var fit = Fit();
foreach ( var section in placed )
{
if ( section.Tiles.Count == 0 )
{
continue;
}
foreach ( var header in section.Headers )
{
canvas.Layout.Add( header );
}
var rows = new List<Layout>();
Layout row = null;
for ( var i = 0; i < section.Tiles.Count; i++ )
{
if ( i % fit == 0 )
{
row = canvas.Layout.AddRow();
row.Spacing = padding;
rows.Add( row );
}
row.Add( section.Tiles[i] );
}
foreach ( var trailing in rows )
{
trailing.AddStretchCell();
}
}
canvas.Layout.AddStretchCell();
columns = fit;
}
int Fit()
{
var usable = Math.Max( Size.x - padding * 2f - 8f, tileSize.x );
return Math.Max( 1, (int)((usable + padding) / (tileSize.x + padding)) );
}
protected override void OnResize()
{
base.OnResize();
if ( placed.Count > 0 && Fit() != columns )
{
Arrange();
}
}
}