editor/AdvancedMappingTools/UI/PropertySheet.cs

Editor sidebar UI for the Advanced Mapping Tools package. Builds a property sheet from [Property] attributes grouped into foldable sections, with per-row reset buttons, ShowIf/HideIf visibility, a search and changed-only filter, a simple/full view toggle, tool header with help box, and a notice widget. Fold and view state is stored in editor cookies.

Reflection
namespace AdvancedMappingTools;

/// <summary>
/// Fills a sidebar with the [Property] values of an object: one foldable section per [Group],
/// one compact row per value, name on the left and control on the right. A changed value has a
/// dot and a button that puts it back to its default. Values that [ShowIf]/[HideIf] rule out are
/// hidden as long as they do not apply. The groups of the tool itself come first, the ones every
/// builder shares come last in a fixed order, so a tool opens on what makes it different.
/// </summary>
public static class PropertySheet
{
	/// <summary>
	/// Groups every builder has, in the order they close the sidebar.
	/// </summary>
	// Properties, not fields: a hotload keeps the old contents of a static field.
	static string[] Shared => ["Variation", "Prop", "Placing", "Materials", "Curves", "Placement"];

	public const string AdvancedTitle = "Advanced";

	/// <summary>
	/// Groups the simple view leaves out: how points snap and curve, random turns and spread.
	/// The tool's own groups, size and materials stay.
	/// </summary>
	static string[] Expert => ["Variation", "Placing", "Curves", "Placement"];

	/// <summary>
	/// A clearer name in the sidebar for a shared group.
	/// </summary>
	static Dictionary<string, string> Titles => new() { ["Placement"] = "Placement & Snapping", ["Prop"] = "Size & Wear" };

	/// <summary>
	/// Adds the sections and returns the sheet, so a <see cref="SettingsFilter"/> can search it.
	/// </summary>
	public static Sheet Fill( ToolSidebarWidget sidebar, SerializedObject settings, string defaultGroup, IReadOnlySet<string> skip = null ) =>
		Fill( sidebar, [settings], defaultGroup, skip );

	/// <summary>
	/// The values of several objects in one sheet: groups of the same name are merged, and the
	/// shared groups of all of them close the sidebar together.
	/// </summary>
	public static Sheet Fill( ToolSidebarWidget sidebar, IEnumerable<SerializedObject> objects, string defaultGroup, IReadOnlySet<string> skip = null )
	{
		var sheet = new Sheet();
		var list = objects.Where( x => x is not null ).ToList();
		var shown = list.SelectMany( o => o.Where( x => x.HasAttribute<PropertyAttribute>() && skip?.Contains( x.Name ) != true ) ).ToList();

		var groups = shown
			.Where( x => !x.HasAttribute<AdvancedAttribute>() )
			.GroupBy( x => string.IsNullOrEmpty( x.GroupName ) ? defaultGroup : x.GroupName )
			.OrderBy( x => Array.IndexOf( Shared, x.Key ) );

		foreach ( var properties in groups )
			sheet.Add( sidebar, Titles.GetValueOrDefault( properties.Key, properties.Key ), properties, folded: false, expert: Expert.Contains( properties.Key ) );

		// One place for what most people never touch, folded until someone opens it.
		var advanced = shown.Where( x => x.HasAttribute<AdvancedAttribute>() ).ToList();
		if ( advanced.Count > 0 ) sheet.Add( sidebar, AdvancedTitle, advanced, folded: true, expert: true );

		foreach ( var o in list ) o.OnPropertyChanged += _ => sheet.Refresh();
		sheet.Refresh();
		return sheet;
	}

	/// <summary>
	/// The rows of one object, in their sections. <see cref="Refresh"/> applies the search and
	/// the conditions without building any control again, so typing in a field keeps its focus.
	/// </summary>
	public sealed class Sheet
	{
		readonly List<(SettingsSection Section, List<SettingRow> Rows, bool Expert)> _sections = [];

		public string Filter { get; private set; } = "";
		public bool ChangedOnly { get; private set; }
		public bool Simple { get; private set; }

		/// <summary>How many settings the simple view hides right now.</summary>
		public int HiddenBySimple => Simple ? _sections.Where( s => s.Expert ).Sum( s => s.Rows.Count( r => r.Applies ) ) : 0;

		public int ChangedCount => _sections.Sum( s => s.Rows.Count( r => r.Applies && r.Changed ) );

		public event Action Refreshed;

		internal void Add( ToolSidebarWidget sidebar, string title, IEnumerable<SerializedProperty> properties, bool folded, bool expert = false )
		{
			var section = new SettingsSection( sidebar, title, folded );
			var rows = new List<SettingRow>();

			foreach ( var property in properties )
			{
				var row = new SettingRow( section.Body, property, title ) { Changed_ = Refresh };
				section.Body.Layout.Add( row );
				rows.Add( row );
			}

			sidebar.Layout.Add( section );
			_sections.Add( (section, rows, expert) );
		}

		public void Search( string filter, bool changedOnly, bool simple = false )
		{
			Filter = filter?.Trim() ?? "";
			ChangedOnly = changedOnly;
			Simple = simple;
			Refresh();
		}

		public void Refresh()
		{
			foreach ( var (section, rows, expert) in _sections )
			{
				var visible = 0;
				var changed = 0;

				foreach ( var row in rows )
				{
					var show = row.Applies && row.Matches( Filter ) && (!ChangedOnly || row.Changed);
					row.Visible = show;
					row.Update();
					if ( show ) visible++;
					if ( row.Applies && row.Changed ) changed++;
				}

				// The simple view leaves expert groups out, unless a search or the changed filter asks for them.
				section.Visible = visible > 0 && !(Simple && expert && Filter == "" && !ChangedOnly);
				section.ChangedCount = changed;
				// A search opens what it finds, without forgetting what was folded before.
				section.ForceOpen = Filter != "" || ChangedOnly;
				section.Update();
			}

			Refreshed?.Invoke();
		}
	}
}

/// <summary>
/// A titled, foldable part of a sidebar in the Davelix Construct look. Remembers being folded by
/// its title, so a section closed in one tool is closed in every tool.
/// </summary>
public sealed class SettingsSection : Widget
{
	const string FoldedCookie = "AMT.FoldedSections";
	const string OpenedCookie = "AMT.OpenedSections";

	readonly string _title;
	readonly Header _header;

	public Widget Body { get; }
	public int ChangedCount { get; set; }

	bool _forceOpen;
	public bool ForceOpen
	{
		get => _forceOpen;
		set { _forceOpen = value; Body.Visible = !Folded || value; }
	}

	public SettingsSection( Widget parent, string title, bool foldedByDefault = false ) : base( parent )
	{
		_title = title;
		Layout = Layout.Column();
		Layout.Margin = new Sandbox.UI.Margin( 4, 2, 4, 2 );
		Layout.Spacing = 0;

		_header = Layout.Add( new Header( this ) );
		Body = Layout.Add( new Widget( this ) { Layout = Layout.Column() } );
		Body.Layout.Margin = new Sandbox.UI.Margin( 0, 2, 0, 6 );
		Body.Layout.Spacing = 2;

		_foldedByDefault = foldedByDefault;
		Body.Visible = !Folded;
	}

	readonly bool _foldedByDefault;

	bool Folded =>
		_foldedByDefault
			? !EditorCookie.Get<List<string>>( OpenedCookie, [] ).Contains( _title )
			: EditorCookie.Get<List<string>>( FoldedCookie, [] ).Contains( _title );

	void Toggle()
	{
		var cookie = _foldedByDefault ? OpenedCookie : FoldedCookie;
		var list = EditorCookie.Get<List<string>>( cookie, [] );
		if ( !list.Remove( _title ) ) list.Add( _title );
		EditorCookie.Set( cookie, list );

		Body.Visible = !Folded || ForceOpen;
		_header.Update();
	}

	sealed class Header : Widget
	{
		readonly SettingsSection _section;

		public Header( SettingsSection section ) : base( section )
		{
			_section = section;
			FixedHeight = 28;
		}

		protected override void OnPaint()
		{
			var rect = LocalRect;
			var open = _section.Body.Visible;

			Paint.ClearPen();
			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( Theme.Text );
			Paint.SetDefaultFont( 8, 600, false, false );
			Paint.DrawText( new Rect( 26, 0, rect.Width - 80, rect.Height ), _section._title, TextFlag.LeftCenter | TextFlag.SingleLine );

			if ( _section.ChangedCount > 0 )
			{
				var text = $"{_section.ChangedCount} changed";
				Paint.SetDefaultFont( 7, 400, false, false );
				Paint.SetPen( Theme.Primary.Lighten( 0.35f ) );
				Paint.DrawText( new Rect( rect.Width - 90, 0, 82, rect.Height ), text, TextFlag.RightCenter | TextFlag.SingleLine );
			}
		}

		protected override void OnMouseEnter() => Update();
		protected override void OnMouseLeave() => Update();

		protected override void OnMousePress( MouseEvent e )
		{
			if ( e.LeftMouseButton && !_section.ForceOpen ) _section.Toggle();
		}
	}
}

/// <summary>
/// One value: its name on the left, the editor control on the right, and a button that puts a
/// changed value back to its default. The name's tooltip says what the value does and its default.
/// </summary>
public sealed class SettingRow : Widget
{
	readonly SerializedProperty _property;
	readonly string _section;

	internal Action Changed_ { get; set; }

	public SettingRow( Widget parent, SerializedProperty property, string section ) : base( parent )
	{
		_property = property;
		_section = section;

		// An asset picker only keeps to the panel's width in the editor's own row.
		if ( IsAsset( property ) )
		{
			Layout = Layout.Row();
			Layout.Margin = new Sandbox.UI.Margin( 0, 0, 4, 0 );
			Layout.Spacing = 2;
			MinimumHeight = Theme.RowHeight;
			Layout.Add( ControlSheet.CreateRow( property ), 1 );
			Layout.Add( new ResetButton( this ) );
			return;
		}

		var control = ControlWidget.Create( property );
		var stacked = control is not null && NeedsFullWidth( property );

		Layout = stacked ? Layout.Column() : Layout.Row();
		Layout.Margin = new Sandbox.UI.Margin( 8, 0, 4, stacked ? 2 : 0 );
		Layout.Spacing = stacked ? 2 : 6;
		MinimumHeight = Theme.RowHeight;

		// A choice with long names gets the full width under its name, beside it the
		// buttons would push every other control out of the panel.
		var top = stacked ? Layout.AddRow() : Layout;
		var name = top.Add( new NameLabel( this, property ), 2 );
		name.MinimumWidth = 90;
		if ( stacked ) top.Add( new ResetButton( this ) );

		if ( control is not null )
		{
			control.MinimumHeight = Theme.RowHeight;
			control.MinimumWidth = 0;
			Layout.Add( control, stacked ? 0 : 3 );
		}

		if ( !stacked ) Layout.Add( new ResetButton( this ) );
	}

	static bool IsAsset( SerializedProperty property ) =>
		typeof( Resource ).IsAssignableFrom( Nullable.GetUnderlyingType( property.PropertyType ) ?? property.PropertyType );

	/// <summary>
	/// True for a choice whose names together are too long to sit beside a name.
	/// </summary>
	static bool NeedsFullWidth( SerializedProperty property )
	{
		var type = Nullable.GetUnderlyingType( property.PropertyType ) ?? property.PropertyType;
		if ( !type.IsEnum ) return false;

		var names = type.GetFields( System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static )
			.Select( f => f.GetCustomAttributes( false ).OfType<TitleAttribute>().FirstOrDefault()?.Value ?? f.Name )
			.ToList();

		// Many choices become a drop-down, which fits beside its name anyway. Segments share the width
		// evenly, so one long name ("Shadows Only" beside "On" and "Off") needs the full width too.
		return names.Count <= 4 && (names.Sum( x => x.Length ) > 26 || names.Max( x => x.Length ) * names.Count > 24);
	}

	/// <summary>False while a [ShowIf] or [HideIf] says the value does not matter.</summary>
	public bool Applies => _property.ShouldShow();

	public bool Changed
	{
		get
		{
			try
			{
				var value = _property.GetValue<object>();
				var fallback = _property.GetDefault();
				static bool Empty( object o ) => o is null or string { Length: 0 };
				// A list counts as changed by what it holds, not by being another list object.
				if ( value is System.Collections.IEnumerable list && value is not string )
				{
					var items = list.Cast<object>().ToList();
					var defaults = (fallback as System.Collections.IEnumerable)?.Cast<object>().ToList() ?? [];
					return items.Count != defaults.Count || items.Where( ( x, i ) => !Equals( x, defaults[i] ) ).Any();
				}
				if ( Empty( value ) && Empty( fallback ) ) return false;
				if ( value is null || fallback is null ) return true;
				// A default written as 384 comes back as a whole number for a float setting; compare by value.
				static bool Number( object o ) => o is float or double or decimal or int or long or short or byte or uint or ulong or ushort or sbyte;
				if ( Number( value ) && Number( fallback ) )
					return Math.Abs( Convert.ToDouble( value ) - Convert.ToDouble( fallback ) ) > 1e-6;
				return !value.Equals( fallback );
			}
			catch
			{
				return false;
			}
		}
	}

	public bool Matches( string filter ) =>
		filter == ""
		|| (_property.DisplayName ?? "").Contains( filter, StringComparison.OrdinalIgnoreCase )
		|| (_property.Name ?? "").Contains( filter, StringComparison.OrdinalIgnoreCase )
		|| (_property.Description ?? "").Contains( filter, StringComparison.OrdinalIgnoreCase )
		|| _section.Contains( filter, StringComparison.OrdinalIgnoreCase );

	void Reset()
	{
		_property.SetValue( _property.GetDefault() );
		Changed_?.Invoke();
	}

	sealed class NameLabel : Widget
	{
		readonly SerializedProperty _property;
		readonly SettingRow _row;

		public NameLabel( SettingRow row, SerializedProperty property ) : base( row )
		{
			_row = row;
			_property = property;
			MinimumHeight = Theme.RowHeight;
			var fallback = property.GetDefault();
			var text = fallback is null or string { Length: 0 } ? "" : $"Default: {fallback}";
			ToolTip = string.Join( "\n", new[] { property.DisplayName, property.Description, text }.Where( x => !string.IsNullOrWhiteSpace( x ) ) );
		}

		protected override void OnPaint()
		{
			var rect = LocalRect;
			var changed = _row.Changed;

			if ( changed )
			{
				Paint.ClearPen();
				Paint.SetBrush( Theme.Primary );
				Paint.DrawCircle( new Vector2( 2, Theme.RowHeight * 0.5f ), new Vector2( 5, 5 ) );
			}

			Paint.SetPen( changed ? Theme.Text : Theme.TextLight );
			Paint.SetDefaultFont( 8, changed ? 500 : 400, false, false );
			var area = new Rect( 9, 0, rect.Width - 9, Theme.RowHeight );
			Paint.DrawText( area, Paint.GetElidedText( _property.DisplayName, area.Width, ElideMode.Right, TextFlag.SingleLine ), TextFlag.LeftCenter | TextFlag.SingleLine );
		}
	}

	sealed class ResetButton : Widget
	{
		readonly SettingRow _row;

		public ResetButton( SettingRow row ) : base( row )
		{
			_row = row;
			FixedWidth = 20;
			FixedHeight = Theme.RowHeight;
		}

		protected override void OnPaint()
		{
			if ( !_row.Changed ) return;

			if ( IsUnderMouse )
			{
				Paint.ClearPen();
				Paint.SetBrush( Theme.SurfaceBackground.Lighten( 0.2f ) );
				Paint.DrawRect( LocalRect, 4 );
			}

			Paint.SetPen( IsUnderMouse ? Theme.Text : Theme.TextLight );
			Paint.DrawIcon( LocalRect, "undo", 14, TextFlag.Center );
		}

		protected override void OnMouseEnter() { ToolTip = _row.Changed ? "Back to the default" : ""; Update(); }
		protected override void OnMouseLeave() => Update();

		protected override void OnMousePress( MouseEvent e )
		{
			if ( e.LeftMouseButton && _row.Changed ) _row.Reset();
		}
	}
}

/// <summary>
/// The bar on top of a settings sidebar: a search over the names and descriptions of every value,
/// and a switch that shows only the values changed from their defaults.
/// </summary>
public sealed class SettingsFilter : Widget
{
	readonly List<PropertySheet.Sheet> _sheets = [];
	readonly LineEdit _search;
	readonly ChangedToggle _changed;
	readonly ViewToggle _view;
	readonly string _cookie;

	/// <param name="key">Remembers the simple or full view under this name, usually the tool's type.</param>
	public SettingsFilter( Widget parent, string key = null ) : base( parent )
	{
		Layout = Layout.Row();
		Layout.Margin = new Sandbox.UI.Margin( 6, 6, 6, 4 );
		Layout.Spacing = 4;

		_search = Layout.Add( new LineEdit( this ) { PlaceholderText = "Find a setting…", ClearButtonEnabled = true }, 1 );
		_search.FixedHeight = Theme.ControlHeight + 2;
		_search.TextEdited += _ => Apply();

		_changed = Layout.Add( new ChangedToggle( this ) { Toggled = Apply } );

		_cookie = key is null ? null : $"AMT.SimpleView.{key}";
		_view = Layout.Add( new ViewToggle( this ) { Simple = _cookie is not null && EditorCookie.Get( _cookie, false ) } );
		_view.Toggled = () =>
		{
			if ( _cookie is not null ) EditorCookie.Set( _cookie, _view.Simple );
			Apply();
		};
		_view.Visible = key is not null;
	}

	public void Watch( PropertySheet.Sheet sheet )
	{
		if ( sheet is null ) return;
		_sheets.Add( sheet );
		sheet.Refreshed += () => { _changed.Update(); _view.Update(); };
		if ( _view.Simple ) sheet.Search( _search.Text, _changed.On, true );
	}

	int ChangedCount => _sheets.Sum( x => x.ChangedCount );

	void Apply()
	{
		foreach ( var sheet in _sheets )
			sheet.Search( _search.Text, _changed.On, _view.Simple );
	}

	int HiddenCount => _sheets.Sum( x => x.HiddenBySimple );

	/// <summary>
	/// Switches between the simple view and every setting. Shows how many settings it hides.
	/// </summary>
	sealed class ViewToggle : Widget
	{
		readonly SettingsFilter _filter;
		public bool Simple { get; set; }
		public Action Toggled { get; set; }

		public ViewToggle( SettingsFilter filter ) : base( filter )
		{
			_filter = filter;
			FixedHeight = Theme.ControlHeight + 2;
			FixedWidth = 58;
		}

		protected override void OnPaint()
		{
			Paint.ClearPen();
			Paint.SetBrush( IsUnderMouse ? Theme.SurfaceBackground.Lighten( 0.15f ) : Theme.SurfaceBackground );
			Paint.DrawRect( LocalRect, 4 );

			Paint.SetPen( Simple ? Theme.Primary.Lighten( 0.35f ) : Theme.TextLight );
			Paint.SetDefaultFont( 8, Simple ? 600 : 400, false, false );
			Paint.DrawText( LocalRect, Simple ? "Simple" : "Full", TextFlag.Center | TextFlag.SingleLine );
		}

		protected override void OnMouseEnter()
		{
			ToolTip = Simple
				? $"Simple view: {_filter.HiddenCount} expert settings (snapping, curves, placing, advanced) are hidden. Click for every setting."
				: "Every setting is shown. Click for the simple view, which hides snapping, curves, placing and advanced settings for this tool.";
			Update();
		}

		protected override void OnMouseLeave() => Update();

		protected override void OnMousePress( MouseEvent e )
		{
			if ( !e.LeftMouseButton ) return;
			Simple = !Simple;
			OnMouseEnter();
			Toggled?.Invoke();
		}
	}

	sealed class ChangedToggle : Widget
	{
		readonly SettingsFilter _filter;
		public bool On { get; private set; }
		public Action Toggled { get; set; }

		public ChangedToggle( SettingsFilter filter ) : base( filter )
		{
			_filter = filter;
			FixedHeight = Theme.ControlHeight + 2;
			FixedWidth = 92;
			ToolTip = "Show only the settings changed from their defaults";
		}

		protected override void OnPaint()
		{
			var count = _filter.ChangedCount;
			Paint.ClearPen();
			Paint.SetBrush( On ? Theme.Primary.WithAlpha( 0.35f ) : IsUnderMouse ? Theme.SurfaceBackground.Lighten( 0.15f ) : Theme.SurfaceBackground );
			Paint.DrawRect( LocalRect, 4 );

			Paint.SetPen( On ? Theme.Text : count > 0 ? Theme.Primary.Lighten( 0.35f ) : Theme.TextLight );
			Paint.SetDefaultFont( 8, On ? 600 : 400, false, false );
			Paint.DrawText( LocalRect, count == 0 ? "No changes" : $"{count} changed", TextFlag.Center | TextFlag.SingleLine );
		}

		protected override void OnMouseEnter() => Update();
		protected override void OnMouseLeave() => Update();

		protected override void OnMousePress( MouseEvent e )
		{
			if ( !e.LeftMouseButton ) return;
			if ( !On && _filter.ChangedCount == 0 ) return;
			On = !On;
			Update();
			Toggled?.Invoke();
		}
	}
}

/// <summary>
/// The top of a tool's sidebar: its icon, name and group, and what it does. A help button opens
/// the hints for using it, the mouse and keys; whether help is open is remembered for all tools.
/// </summary>
public sealed class ToolHeader : Widget
{
	const string HelpCookie = "AMT.HelpOpen";

	readonly string _icon;
	readonly string _title;
	readonly string _group;
	readonly Widget _help;

	public ToolHeader( Widget parent, TypeDescription tool, IEnumerable<string> hints = null ) : base( parent )
	{
		_icon = string.IsNullOrEmpty( tool?.Icon ) ? "build" : tool.Icon;
		_title = tool?.Title ?? "Tool";
		_group = tool is null ? "" : ToolCatalog.CategoryOf( tool );

		Layout = Layout.Column();
		Layout.Margin = new Sandbox.UI.Margin( 8, 8, 8, 2 );
		Layout.Spacing = 4;
		Layout.Add( new TitleRow( this, hints?.Any() == true ) );

		if ( !string.IsNullOrWhiteSpace( tool?.Description ) )
			Layout.Add( new Label( tool.Description, this ) { WordWrap = true, Color = Theme.TextLight } );

		_help = Layout.Add( new HelpBox( this, hints ?? [] ) );
		_helpOpen = hints?.Any() == true && EditorCookie.Get( HelpCookie, false );
		_help.Visible = _helpOpen;
	}

	// Kept apart from the widget: a widget not on screen yet always reads as not visible.
	bool _helpOpen;
	bool HelpOpen => _helpOpen;

	void ToggleHelp()
	{
		_helpOpen = !_helpOpen;
		_help.Visible = _helpOpen;
		EditorCookie.Set( HelpCookie, _helpOpen );
	}

	sealed class TitleRow : Widget
	{
		readonly ToolHeader _header;
		readonly bool _hasHelp;
		Vector2 _mouse;

		public TitleRow( ToolHeader header, bool hasHelp ) : base( header )
		{
			_header = header;
			_hasHelp = hasHelp;
			FixedHeight = 36;
		}

		Rect HelpRect => new( Width - 28, 6, 26, 26 );

		protected override void OnPaint()
		{
			var box = new Rect( 0, 2, 32, 32 );
			Paint.ClearPen();
			Paint.SetBrush( Theme.Primary.WithAlpha( 0.2f ) );
			Paint.DrawRect( box, 6 );
			Paint.SetPen( Theme.Primary.Lighten( 0.4f ) );
			Paint.DrawIcon( box, _header._icon, 20, TextFlag.Center );

			var textWidth = Width - 42 - (_hasHelp ? 32 : 0);
			Paint.SetPen( Theme.Text );
			Paint.SetHeadingFont( 10, 600, false, false );
			Paint.DrawText( new Rect( 42, 1, textWidth, 18 ), _header._title, TextFlag.LeftCenter | TextFlag.SingleLine );

			Paint.SetPen( Theme.TextLight );
			Paint.SetDefaultFont( 8, 400, false, false );
			Paint.DrawText( new Rect( 42, 19, textWidth, 16 ), _header._group, TextFlag.LeftCenter | TextFlag.SingleLine );

			if ( !_hasHelp ) return;
			var help = HelpRect;
			var over = IsUnderMouse && help.IsInside( _mouse );
			if ( over || _header.HelpOpen )
			{
				Paint.ClearPen();
				Paint.SetBrush( _header.HelpOpen ? Theme.Primary.WithAlpha( 0.3f ) : Theme.SurfaceBackground.Lighten( 0.15f ) );
				Paint.DrawRect( help, 4 );
			}
			Paint.SetPen( _header.HelpOpen ? Theme.Text : Theme.TextLight );
			Paint.DrawIcon( help, "help_outline", 17, TextFlag.Center );
		}

		protected override void OnMouseMove( MouseEvent e )
		{
			_mouse = e.LocalPosition;
			ToolTip = _hasHelp && HelpRect.IsInside( _mouse ) ? (_header.HelpOpen ? "Hide the help" : "How to use this tool") : "";
			Update();
		}

		protected override void OnMouseLeave() => Update();

		protected override void OnMousePress( MouseEvent e )
		{
			if ( !e.LeftMouseButton || !_hasHelp || !HelpRect.IsInside( e.LocalPosition ) ) return;
			_header.ToggleHelp();
			Update();
		}
	}

	/// <summary>
	/// The hints of a tool as a list, one line each, keys and mouse buttons in front.
	/// </summary>
	sealed class HelpBox : Widget
	{
		public HelpBox( Widget parent, IEnumerable<string> hints ) : base( parent )
		{
			Layout = Layout.Column();
			Layout.Margin = new Sandbox.UI.Margin( 12, 8, 8, 8 );
			Layout.Spacing = 3;
			Layout.Add( new Label( "How to use", this ) { Color = Theme.Text } );
			foreach ( var hint in hints )
				Layout.Add( new Label( "•  " + hint, this ) { WordWrap = true, Color = Theme.TextLight } );
		}

		protected override void OnPaint()
		{
			Paint.ClearPen();
			Paint.SetBrush( Theme.SurfaceBackground );
			Paint.DrawRect( LocalRect, 4 );
			Paint.SetBrush( Theme.Primary.WithAlpha( 0.6f ) );
			Paint.DrawRect( new Rect( 0, 0, 3, Height ), 1.5f );
		}
	}
}

/// <summary>
/// A short warning on top of a sidebar, with a coloured bar so it is not overlooked.
/// </summary>
public sealed class Notice : Widget
{
	public Notice( Widget parent, string title, string text ) : base( parent )
	{
		Layout = Layout.Column();
		Layout.Margin = new Sandbox.UI.Margin( 14, 8, 8, 8 );
		Layout.Spacing = 2;
		Layout.Add( new Label( title, this ) { Color = Theme.Yellow } );
		Layout.Add( new Label( text, this ) { WordWrap = true, Color = Theme.Text } );
	}

	protected override void OnPaint()
	{
		var rect = LocalRect.Shrink( 4, 2, 4, 2 );
		Paint.ClearPen();
		Paint.SetBrush( Theme.Yellow.WithAlpha( 0.08f ) );
		Paint.DrawRect( rect, 4 );
		Paint.SetBrush( Theme.Yellow );
		Paint.DrawRect( new Rect( rect.Left, rect.Top, 3, rect.Height ), 1.5f );
	}
}