editor/AdvancedMappingTools/Core/ToolPanel.cs

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