Editor dock panel for the Davelix Construct tool browser. Lists construction tools with search over names, groups, descriptions and German/English aliases, plus favorites, recents and collapsible groups, and opens the selected tool.
namespace AdvancedMappingTools;
/// <summary>
/// The Davelix Construct panel: a tool browser. A search over names, groups, descriptions and
/// German and English keywords; favorites and recent tools on top; every group a collapsible
/// row with its count. The open tool is marked. Open it from the View menu and dock it anywhere.
/// </summary>
[Dock( "Editor", "Davelix Construct", "construction" )]
public sealed class ToolPanel : Widget
{
const string OpenGroupsCookie = "AMT.OpenGroups";
const string EditorTypeCookie = "PrimitiveTool.EditorType";
/// <summary>
/// Words a builder of a group can also be found by: the panel is used in German and
/// English scenes alike, and nobody should have to guess the English group name.
/// </summary>
static readonly Dictionary<string, string[]> SearchAliases = new()
{
["Buildings"] = ["gebäude", "gebaeude", "haus", "building", "house"],
["Walls & Floors"] = ["mauer", "wand", "boden", "decke", "wall", "floor"],
["Doors & Windows"] = ["tür", "tuer", "tor", "fenster", "door", "window", "gate"],
["Roofs"] = ["dach", "gaube", "roof"],
["Stairs & Ramps"] = ["treppe", "rampe", "stufe", "stair", "ramp", "step"],
["Arches & Vaults"] = ["bogen", "gewölbe", "gewoelbe", "arch", "vault"],
["Tunnels & Bridges"] = ["tunnel", "brücke", "bruecke", "bridge"],
["Roads & Paths"] = ["straße", "strasse", "weg", "pfad", "road", "street", "path"],
["Cutters"] = ["ausschneiden", "öffnung", "oeffnung", "cut", "opening"],
["Columns & Towers"] = ["säule", "saeule", "turm", "column", "tower"],
["Street"] = ["laterne", "straße", "strasse", "street", "lamp"],
["Industrial"] = ["rohr", "leitung", "industrie", "pipe", "industrial"],
["Defense"] = ["verteidigung", "mauer", "defense", "wall"],
["Interior"] = ["innen", "möbel", "moebel", "interior", "furniture"],
["Props"] = ["requisite", "möbel", "moebel", "prop", "detail"],
["Scatter"] = ["streuen", "verteilen", "scatter", "vegetation"]
};
readonly LineEdit _search;
readonly Widget _list;
readonly HeaderBar _header;
public ToolPanel( Widget parent ) : base( parent )
{
Layout = Layout.Column();
Layout.Margin = 0;
Layout.Spacing = 0;
_header = Layout.Add( new HeaderBar( this ) { CollapseAll = () => { SetOpenGroups( [] ); Rebuild(); } } );
var searchRow = Layout.AddRow();
searchRow.Margin = new Sandbox.UI.Margin( 8, 0, 8, 8 );
_search = searchRow.Add( new LineEdit( this ) { PlaceholderText = "Search tools, groups, keywords…", ClearButtonEnabled = true }, 1 );
_search.FixedHeight = Theme.ControlHeight + 4;
_search.TextEdited += _ => Rebuild();
_search.ReturnPressed += OpenFirstHit;
var scroll = Layout.Add( new ScrollArea( this ), 1 );
_list = new Widget( scroll ) { Layout = Layout.Column() };
_list.Layout.Margin = new Sandbox.UI.Margin( 4, 0, 4, 8 );
_list.Layout.Spacing = 1;
scroll.Canvas = _list;
Rebuild();
}
protected override void OnPaint()
{
Paint.ClearPen();
Paint.SetBrush( Theme.WidgetBackground );
Paint.DrawRect( LocalRect );
}
protected override void OnKeyPress( KeyEvent e )
{
if ( e.Key == KeyCode.Escape && !string.IsNullOrEmpty( _search.Text ) )
{
_search.Text = "";
Rebuild();
e.Accepted = true;
return;
}
base.OnKeyPress( e );
}
string Filter => _search.Text?.Trim() ?? "";
static string ActiveTool => EditorCookie.GetString( EditorTypeCookie, "" );
static List<string> OpenGroups => EditorCookie.Get<List<string>>( OpenGroupsCookie, [] );
static void SetOpenGroups( List<string> groups ) => EditorCookie.Set( OpenGroupsCookie, groups );
/// <summary>
/// Builds the list again: the search results, or favorites, recent tools and the groups.
/// </summary>
void Rebuild()
{
_list.Layout.Clear( true );
var tools = ToolCatalog.Tools.ToList();
_header.Summary = $"{tools.Count} tools in {ToolCatalog.Groups.Count( g => tools.Any( t => ToolCatalog.CategoryOf( t ) == g.Name ) )} groups";
_header.Update();
if ( Filter != "" )
{
var hits = Hits( tools );
if ( hits.Count == 0 )
{
_list.Layout.Add( new Message( _list, $"No tool matches “{Filter}”.", "Try a group name or a German word: mauer, treppe, dach, rohr, straße." ) );
}
else
{
_list.Layout.Add( new Caption( _list, hits.Count == 1 ? "1 result" : $"{hits.Count} results", "Enter opens the first" ) );
foreach ( var tool in hits ) AddTool( tool, showGroup: true, indent: false );
}
_list.Layout.AddStretchCell();
return;
}
var favorites = ToolCatalog.Favorites;
var favoriteTools = tools.Where( x => favorites.Contains( x.FullName ) ).ToList();
var recentTools = ToolCatalog.Recent.Select( name => tools.FirstOrDefault( x => x.FullName == name ) ).Where( x => x is not null ).ToList();
if ( favoriteTools.Count > 0 )
{
_list.Layout.Add( new Caption( _list, "Favorites" ) );
foreach ( var tool in favoriteTools ) AddTool( tool, showGroup: true, indent: false );
}
if ( recentTools.Count > 0 )
{
_list.Layout.Add( new Caption( _list, "Recent" ) );
foreach ( var tool in recentTools ) AddTool( tool, showGroup: true, indent: false );
}
_list.Layout.Add( new Caption( _list, "Groups" ) );
var open = OpenGroups;
var active = ActiveTool;
foreach ( var (name, icon) in ToolCatalog.Groups.Append( ("Other", "category") ) )
{
var inGroup = tools.Where( x => ToolCatalog.CategoryOf( x ) == name || name == "Other" && !ToolCatalog.Categories.Contains( ToolCatalog.CategoryOf( x ) ) ).ToList();
if ( inGroup.Count == 0 ) continue;
var isOpen = open.Contains( name );
var group = name;
_list.Layout.Add( new GroupRow( _list, name, icon, inGroup.Count, isOpen, inGroup.Any( x => x.FullName == active ) )
{
Toggled = () =>
{
var groups = OpenGroups;
if ( !groups.Remove( group ) ) groups.Add( group );
SetOpenGroups( groups );
Rebuild();
}
} );
if ( isOpen )
foreach ( var tool in inGroup ) AddTool( tool, showGroup: false, indent: true );
}
_list.Layout.AddStretchCell();
}
/// <summary>
/// A search finds a tool by its name, its group, what it says about itself, or one of the
/// German and English words the group is known by. Names that start with the search come first.
/// </summary>
List<TypeDescription> Hits( List<TypeDescription> tools )
{
var filter = Filter;
bool Has( string text ) => (text ?? "").Contains( filter, StringComparison.OrdinalIgnoreCase );
int Rank( TypeDescription tool ) =>
tool.Title.StartsWith( filter, StringComparison.OrdinalIgnoreCase ) ? 0
: Has( tool.Title ) ? 1
: Has( ToolCatalog.CategoryOf( tool ) ) || Aliases( ToolCatalog.CategoryOf( tool ) ).Any( Has ) ? 2
: 3;
return tools.Where( x => Has( x.Title ) || Has( x.FullName ) || Has( ToolCatalog.CategoryOf( x ) ) || Has( x.Description ) || Aliases( ToolCatalog.CategoryOf( x ) ).Any( Has ) )
.OrderBy( Rank ).ThenBy( x => x.Title ).ToList();
}
static IEnumerable<string> Aliases( string category ) =>
SearchAliases.TryGetValue( category, out var words ) ? words : [];
void OpenFirstHit()
{
if ( Filter == "" ) return;
var first = Hits( ToolCatalog.Tools.ToList() ).FirstOrDefault();
if ( first is null ) return;
ToolCatalog.Activate( first );
Rebuild();
}
void AddTool( TypeDescription tool, bool showGroup, bool indent )
{
_list.Layout.Add( new ToolRow( _list, tool, showGroup, indent, tool.FullName == ActiveTool, ToolCatalog.IsFavorite( tool ) )
{
Opened = () =>
{
ToolCatalog.Activate( tool );
Rebuild();
},
Starred = () =>
{
ToolCatalog.ToggleFavorite( tool );
Rebuild();
}
} );
}
/// <summary>
/// Panel title, how many tools there are, and a button that folds every group.
/// </summary>
sealed class HeaderBar : Widget
{
public string Summary { get; set; } = "";
public Action CollapseAll { get; set; }
public HeaderBar( Widget parent ) : base( parent )
{
FixedHeight = 48;
ToolTip = "";
}
Rect FoldRect => new( Width - 32, 12, 24, 24 );
protected override void OnPaint()
{
Paint.ClearPen();
Paint.SetBrush( Theme.WidgetBackground );
Paint.DrawRect( LocalRect );
Paint.SetPen( Theme.Primary );
Paint.DrawIcon( new Rect( 10, 12, 22, 24 ), "construction", 18, TextFlag.Center );
Paint.SetPen( Theme.Text );
Paint.SetHeadingFont( 10, 600, false, false );
Paint.DrawText( new Rect( 38, 7, Width - 80, 20 ), "Davelix Construct", TextFlag.LeftCenter | TextFlag.SingleLine );
Paint.SetPen( Theme.TextLight );
Paint.SetDefaultFont( 8, 400, false, false );
Paint.DrawText( new Rect( 38, 25, Width - 80, 16 ), Summary, TextFlag.LeftCenter | TextFlag.SingleLine );
var fold = FoldRect;
if ( IsUnderMouse && fold.IsInside( _mouse ) )
{
Paint.SetBrush( Theme.SurfaceBackground );
Paint.DrawRect( fold, 4 );
}
Paint.SetPen( Theme.TextLight );
Paint.DrawIcon( fold, "unfold_less", 16, TextFlag.Center );
}
Vector2 _mouse;
protected override void OnMouseMove( MouseEvent e )
{
_mouse = e.LocalPosition;
ToolTip = FoldRect.IsInside( _mouse ) ? "Fold all groups" : "";
Update();
}
protected override void OnMouseLeave() => Update();
protected override void OnMousePress( MouseEvent e )
{
if ( e.LeftMouseButton && FoldRect.IsInside( e.LocalPosition ) ) CollapseAll?.Invoke();
}
}
/// <summary>
/// A small heading over a part of the list, with an optional note on the right.
/// </summary>
sealed class Caption : Widget
{
readonly string _text;
readonly string _note;
public Caption( Widget parent, string text, string note = null ) : base( parent )
{
_text = text;
_note = note;
FixedHeight = 26;
}
protected override void OnPaint()
{
var rect = LocalRect.Shrink( 8, 8, 8, 0 );
Paint.SetDefaultFont( 7, 600, false, false );
Paint.SetPen( Theme.TextLight );
Paint.DrawText( rect, _text.ToUpperInvariant(), TextFlag.LeftCenter | TextFlag.SingleLine );
if ( string.IsNullOrEmpty( _note ) ) return;
Paint.SetDefaultFont( 7, 400, false, false );
Paint.SetPen( Theme.TextLight.WithAlpha( 0.7f ) );
Paint.DrawText( rect, _note, TextFlag.RightCenter | TextFlag.SingleLine );
}
}
/// <summary>
/// A text in the middle of the list, for when there is nothing to show.
/// </summary>
sealed class Message : Widget
{
readonly string _title;
readonly string _hint;
public Message( Widget parent, string title, string hint ) : base( parent )
{
_title = title;
_hint = hint;
FixedHeight = 72;
}
protected override void OnPaint()
{
Paint.SetPen( Theme.Text );
Paint.SetDefaultFont( 8, 500, false, false );
Paint.DrawText( new Rect( 12, 16, Width - 24, 18 ), _title, TextFlag.LeftCenter | TextFlag.SingleLine );
Paint.SetPen( Theme.TextLight );
Paint.SetDefaultFont( 8, 400, false, false );
Paint.DrawText( new Rect( 12, 36, Width - 24, 32 ), _hint, TextFlag.LeftTop | TextFlag.WordWrap );
}
}
/// <summary>
/// A group: chevron, icon, name and how many tools it holds. A click opens or folds it.
/// A dot marks the group of the open tool.
/// </summary>
sealed class GroupRow : Widget
{
readonly string _name;
readonly string _icon;
readonly int _count;
readonly bool _open;
readonly bool _holdsActive;
public Action Toggled { get; set; }
public GroupRow( Widget parent, string name, string icon, int count, bool open, bool holdsActive ) : base( parent )
{
_name = name;
_icon = icon;
_count = count;
_open = open;
_holdsActive = holdsActive;
FixedHeight = 30;
ToolTip = open ? $"Fold {name}" : $"Show the {count} tools of {name}";
}
protected override void OnPaint()
{
var rect = LocalRect;
Paint.ClearPen();
if ( IsUnderMouse || _open )
{
Paint.SetBrush( IsUnderMouse ? Theme.SurfaceBackground.Lighten( 0.1f ) : Theme.SurfaceBackground );
Paint.DrawRect( rect, 4 );
}
Paint.SetPen( Theme.TextLight );
Paint.DrawIcon( new Rect( 6, 0, 16, rect.Height ), _open ? "expand_more" : "chevron_right", 16, TextFlag.Center );
Paint.SetPen( _open ? Theme.Primary.Lighten( 0.3f ) : Theme.TextLight );
Paint.DrawIcon( new Rect( 26, 0, 18, rect.Height ), _icon, 16, TextFlag.Center );
Paint.SetPen( Theme.Text );
Paint.SetDefaultFont( 8, _open ? 600 : 500, false, false );
Paint.DrawText( new Rect( 52, 0, rect.Width - 100, rect.Height ), _name, TextFlag.LeftCenter | TextFlag.SingleLine );
if ( _holdsActive )
{
Paint.ClearPen();
Paint.SetBrush( Theme.Primary );
Paint.DrawCircle( new Vector2( rect.Width - 44, rect.Height * 0.5f ), new Vector2( 6, 6 ) );
}
Paint.SetPen( Theme.TextLight );
Paint.SetDefaultFont( 8, 400, false, false );
Paint.DrawText( new Rect( rect.Width - 36, 0, 28, rect.Height ), $"{_count}", TextFlag.RightCenter | TextFlag.SingleLine );
}
protected override void OnMouseEnter() => Update();
protected override void OnMouseLeave() => Update();
protected override void OnMousePress( MouseEvent e )
{
if ( e.LeftMouseButton ) Toggled?.Invoke();
}
}
/// <summary>
/// One tool: icon and name, its group when it stands outside its group, and a star. A click
/// on the row opens the tool, a click on the star keeps it in Favorites. The open tool has a
/// bar on the left.
/// </summary>
sealed class ToolRow : Widget
{
const float StarWidth = 28.0f;
readonly TypeDescription _tool;
readonly bool _showGroup;
readonly bool _indent;
readonly bool _active;
readonly bool _favorite;
Vector2 _mouse;
public Action Opened { get; set; }
public Action Starred { get; set; }
public ToolRow( Widget parent, TypeDescription tool, bool showGroup, bool indent, bool active, bool favorite ) : base( parent )
{
_tool = tool;
_showGroup = showGroup;
_indent = indent;
_active = active;
_favorite = favorite;
FixedHeight = 28;
ToolTip = string.IsNullOrWhiteSpace( tool.Description ) ? tool.Title : $"{tool.Title}\n{tool.Description}";
}
bool OverStar => _mouse.x > Width - StarWidth;
protected override void OnPaint()
{
var rect = LocalRect;
var left = _indent ? 26.0f : 6.0f;
Paint.ClearPen();
if ( _active )
{
Paint.SetBrush( Theme.Primary.WithAlpha( 0.22f ) );
Paint.DrawRect( rect, 4 );
Paint.SetBrush( Theme.Primary );
Paint.DrawRect( new Rect( 0, 4, 3, rect.Height - 8 ), 1.5f );
}
else if ( IsUnderMouse )
{
Paint.SetBrush( Theme.SurfaceBackground.Lighten( 0.15f ) );
Paint.DrawRect( rect, 4 );
}
Paint.SetPen( _active ? Theme.Primary.Lighten( 0.4f ) : Theme.TextLight );
Paint.DrawIcon( new Rect( left, 0, 18, rect.Height ), string.IsNullOrEmpty( _tool.Icon ) ? "build" : _tool.Icon, 15, TextFlag.Center );
var textRight = rect.Width - StarWidth - 4;
if ( _showGroup )
{
var group = ToolCatalog.CategoryOf( _tool );
Paint.SetDefaultFont( 7, 400, false, false );
var width = MathF.Min( Paint.MeasureText( group ).x + 4, rect.Width * 0.4f );
Paint.SetPen( Theme.TextLight.WithAlpha( 0.8f ) );
Paint.DrawText( new Rect( textRight - width, 0, width, rect.Height ), group, TextFlag.RightCenter | TextFlag.SingleLine );
textRight -= width + 8;
}
Paint.SetPen( Theme.Text );
Paint.SetDefaultFont( 8, _active ? 600 : 400, false, false );
var titleRect = new Rect( left + 26, 0, MathF.Max( textRight - left - 26, 10 ), rect.Height );
Paint.DrawText( titleRect, Paint.GetElidedText( _tool.Title, titleRect.Width, ElideMode.Right, TextFlag.SingleLine ), TextFlag.LeftCenter | TextFlag.SingleLine );
if ( _favorite || IsUnderMouse )
{
Paint.SetPen( _favorite ? Theme.Yellow : IsUnderMouse && OverStar ? Theme.Text : Theme.TextLight.WithAlpha( 0.6f ) );
Paint.DrawIcon( new Rect( rect.Width - StarWidth, 0, StarWidth - 4, rect.Height ), _favorite ? "star" : "star_border", 15, TextFlag.Center );
}
}
protected override void OnMouseEnter() => Update();
protected override void OnMouseLeave() => Update();
protected override void OnMouseMove( MouseEvent e )
{
_mouse = e.LocalPosition;
Update();
}
protected override void OnMousePress( MouseEvent e )
{
if ( !e.LeftMouseButton ) return;
_mouse = e.LocalPosition;
if ( OverStar ) Starred?.Invoke();
else Opened?.Invoke();
}
}
}