Editor UI helper and layout classes for architecture tool icons. ArchIcons locates PNG icon files across projects, provides slug/glyph strings for various architecture enums, and caches found paths. ArchIconGrid and ArchLabeledIconButton implement a 3-column icon grid and labeled icon buttons for the editor palette.
using System;
using System.Collections.Generic;
using System.IO;
using Editor;
using Sandbox;
namespace Sunless.Architecture;
// Loose PNGs, not Assets - editor chrome, not game content. A dot in the slug makes DrawIcon read a path; no file falls back to a symbol.
public static class ArchIcons
{
const string Folder = "Editor/Icons";
static readonly Dictionary<string, string> located = new();
public static string Get( string slug, string fallback )
{
if ( string.IsNullOrWhiteSpace( slug ) )
{
return fallback;
}
if ( !located.TryGetValue( slug, out var path ) )
{
path = Locate( slug );
located[slug] = path;
}
return path ?? fallback;
}
public static string RoofStyleSlug( RoofStyle style ) => $"roof_{style}".ToLowerInvariant();
public static string RoofStyleGlyph( RoofStyle style ) => style switch
{
RoofStyle.Flat => "horizontal_rule",
RoofStyle.Shed => "signal_cellular_4_bar",
RoofStyle.Gable => "change_history",
RoofStyle.Hip => "roofing",
_ => "show_chart"
};
public static string RidgeSlug( RidgeRun run ) => $"ridge_{run}".ToLowerInvariant();
public static string RidgeGlyph( RidgeRun run ) => run switch
{
RidgeRun.AlongX => "swap_horiz",
RidgeRun.AlongY => "swap_vert",
_ => "auto_fix_high"
};
public static string RidgeAdvice( RidgeRun run ) => run switch
{
RidgeRun.AlongX => "Ridge along X - flat ends face east and west",
RidgeRun.AlongY => "Ridge along Y - flat ends face north and south",
_ => "Auto - ridge down the longer side, flat ends on the short walls"
};
public static void Forget()
{
located.Clear();
}
// Derived from the type name, not a table - a new subtool drops its PNG in beside the others and is
// picked up with no code, which is what a palette in a library has to be able to do.
public static string SubtoolSlug( ArchSubtool subtool )
{
var name = subtool.GetType().Name;
if ( name.StartsWith( "Arch", StringComparison.Ordinal ) )
{
name = name[4..];
}
if ( name.EndsWith( "Subtool", StringComparison.Ordinal ) )
{
name = name[..^"Subtool".Length];
}
return $"subtool_{name.ToLowerInvariant()}";
}
// Kit data is keyed by authored name and falls back to its kind - a new preset needs no art.
public const string ProfileGlyph = "timeline";
public static string OpeningGlyph( ArchOpeningPreset preset )
{
return Get( $"kind_{preset.Kind}".ToLowerInvariant(), ForKind( preset.Kind ) );
}
public static string ProfileSlug( string name )
{
return $"profile_{name}";
}
public static string PillarSlug( string name )
{
return $"pillar_{name}";
}
public static string WallSlug( string name )
{
return $"wall_{name}";
}
static string ForKind( OpeningKind kind ) => kind switch
{
OpeningKind.Archway => "door_sliding",
OpeningKind.Door => "door_front",
OpeningKind.DoubleDoor => "meeting_room",
OpeningKind.Garage => "garage",
OpeningKind.Window => "window",
OpeningKind.Hatch => "crop_square",
_ => "crop_square"
};
// Every loaded project is asked, not just the one that happens to be open, so a subtool library carries its
// own art in its own folder and is still found once it peels out of core. Ordered by root so two projects
// naming one slug resolve the same way every time.
static string Locate( string slug )
{
foreach ( var root in EditorUtility.Projects.GetAll()
.Select( project => project.GetRootPath() )
.Where( root => !string.IsNullOrWhiteSpace( root ) )
.OrderBy( root => root, StringComparer.OrdinalIgnoreCase ) )
{
var path = Path.Combine( root, Folder, $"{slug}.png" ).Replace( '\\', '/' );
if ( File.Exists( path ) )
{
return path;
}
}
return null;
}
}
// Rows seal with a stretch cell as they fill; dispose seals the last row, hence `using`.
public sealed class ArchIconGrid : IDisposable
{
const int Columns = 3;
const float Side = 64f;
const float Height = 68f;
const float Glyph = 30f;
readonly Layout parent;
readonly List<IconButton> exclusive = new();
Layout row;
int placed;
ArchIconGrid( Layout parent )
{
this.parent = parent;
}
public static ArchIconGrid In( Layout parent )
{
return new ArchIconGrid( parent );
}
public void Dispose()
{
Seal();
}
public IconButton Toggle( string tooltip, string slug, string fallback, bool value, Action<bool> assign )
{
var button = Place( tooltip, ArchIcons.Get( slug, fallback ), Caption( tooltip, slug ) );
button.IsToggle = true;
button.IsActive = value;
button.OnToggled = assign;
return button;
}
public IconButton Pick( string tooltip, string slug, string fallback, bool active, Action chosen )
{
return Icon( tooltip, ArchIcons.Get( slug, fallback ), Caption( tooltip, slug ), active, chosen );
}
// For a caller that already owns its icon answer rather than a slug and a fallback.
public IconButton Icon( string tooltip, string icon, bool active, Action chosen )
{
return Icon( tooltip, icon, Caption( tooltip, null ), active, chosen );
}
IconButton Icon( string tooltip, string icon, string caption, bool active, Action chosen )
{
var button = Place( tooltip, icon, caption );
button.IsActive = active;
exclusive.Add( button );
button.OnClick = () =>
{
foreach ( var other in exclusive )
{
other.IsActive = other == button;
}
chosen?.Invoke();
};
return button;
}
IconButton Place( string tooltip, string icon, string caption )
{
var button = new ArchLabeledIconButton( icon, caption )
{
ToolTip = tooltip,
IconSize = Glyph,
FixedWidth = Side,
FixedHeight = Height
};
if ( placed % Columns == 0 )
{
Seal();
row = parent.AddRow();
row.Spacing = 4;
}
row.Add( button );
placed++;
return button;
}
static string Caption( string tooltip, string slug )
{
var described = tooltip.Split( " — ", StringSplitOptions.TrimEntries );
if ( described.Length > 1 )
{
return Compact( described[0] );
}
if ( !string.IsNullOrWhiteSpace( slug ) )
{
var slugWords = Path.GetFileNameWithoutExtension( slug )
.Split( '_', StringSplitOptions.RemoveEmptyEntries );
if ( slugWords.Length > 0 )
{
return Title( slugWords[^1] );
}
}
return Compact( tooltip );
}
static string Compact( string text )
{
var caption = text
.Replace( "Where you drag", "Surface", StringComparison.OrdinalIgnoreCase )
.Replace( " under it", "", StringComparison.OrdinalIgnoreCase )
.Replace( " over it", "", StringComparison.OrdinalIgnoreCase )
.Trim();
if ( caption.Length <= 10 )
{
return caption;
}
var words = caption.Split( ' ', StringSplitOptions.RemoveEmptyEntries );
return words.Length > 0 ? words[0] : caption;
}
static string Title( string caption ) => caption.Length == 0
? caption
: char.ToUpperInvariant( caption[0] ) + caption[1..];
void Seal()
{
if ( row is null )
{
return;
}
row.AddStretchCell();
row = null;
}
}
sealed class ArchLabeledIconButton : IconButton
{
readonly string caption;
public ArchLabeledIconButton( string icon, string caption ) : base( icon )
{
this.caption = caption;
}
protected override void OnPaint()
{
Paint.ClearBrush();
Paint.ClearPen();
var active = Enabled && IsActive;
var background = active ? BackgroundActive : Background;
var foreground = active ? ForegroundActive : Foreground;
Paint.SetBrush( background );
Paint.DrawRect( LocalRect, 2f );
Paint.ClearBrush();
Paint.ClearPen();
Paint.Pen = foreground.WithAlphaMultiplied( Paint.HasMouseOver ? 1f : 0.9f );
if ( !Enabled )
{
Paint.Pen = foreground.WithAlphaMultiplied( 0.25f );
}
Paint.DrawIcon( new Rect( 0f, 4f, Width, 37f ), Icon, IconSize, TextFlag.Center );
var fontSize = caption.Length > 9 ? 7f : caption.Length > 7 ? 7.5f : 8f;
Paint.SetDefaultFont( fontSize, 600 );
Paint.DrawText( new Rect( 3f, 42f, Width - 6f, 20f ), caption, TextFlag.Center );
}
}