Editor/Prism/Ui/GraphOverlayToolbar.cs

An editor UI widget that draws a floating toolbar pinned to the top-left of a graph canvas. It composes a list of buttons, toggles, separators and a zoom readout, handles layout, painting, mouse interaction, and opens an align menu that invokes actions on the associated GraphPanel.

Native Interop
using Editor.Prism.Core;

namespace Editor.Prism.Ui;

/// <summary>
/// The floating control bar that sits over the top-left of the canvas.
/// <para>
/// It is a child <see cref="Widget"/> of the graph view — which is itself a widget — and deliberately
/// <em>not</em> a <c>GraphicsItem</c> added to the scene. An item would pan and zoom with the nodes,
/// which is the one thing a viewport control must never do.
/// </para>
/// <para>
/// Everything is painted here rather than assembled from <c>IconButton</c>s: the built-in button draws
/// itself from the editor theme, and one blue-on-pink control in the corner of an otherwise Prism-toned
/// canvas is exactly the kind of detail that makes a tool feel bolted together.
/// </para>
/// </summary>
public sealed class GraphOverlayToolbar : Widget
{
	/// <summary>Margin between the toolbar and the canvas edges.</summary>
	public const float Inset = 12f;

	const float BarHeight = 30f;
	const float ButtonSize = 26f;
	const float IconSize = 15f;
	const float Padding = 2f;
	const float SeparatorWidth = 9f;
	const float ReadoutWidth = 50f;

	readonly List<Item> _items = new();
	readonly GraphPanel _panel;

	Vector2 _parentSize;
	int _hovered = -1;
	int _pressed = -1;

	/// <summary>Build the overlay for a graph panel.</summary>
	public GraphOverlayToolbar( GraphPanel panel, Widget parent = null ) : base( parent ?? panel )
	{
		_panel = panel;

		MouseTracking = true;
		Cursor = CursorShape.Finger;
		FixedHeight = BarHeight;

		Build();
	}

	/// <summary>Recompute widths and repaint. Call after anything a button reflects has changed.</summary>
	public void Refresh()
	{
		FixedWidth = MeasureWidth();

		Update();
	}

	// ---------------------------------------------------------------- model ----

	void Build()
	{
		_items.Clear();

		Button( PrismIcons.ZoomOut, "Zoom Out", () => _panel?.ZoomBy( 1f / 1.25f ) );
		Readout();
		Button( PrismIcons.ZoomIn, "Zoom In", () => _panel?.ZoomBy( 1.25f ) );

		Separator();

		Button( PrismIcons.Fit, "Fit Graph  ·  Shift+F", () => _panel?.FrameAll() );
		Button( PrismIcons.Frame, "Frame Selection  ·  F", () => _panel?.FrameSelection() );

		Separator();

		Menu( PrismIcons.Align, "Align & Distribute", OpenAlignMenu );
		Button( PrismIcons.AutoLayout, "Auto-Layout", () => _panel?.AutoLayout() );

		Separator();

		Toggle( PrismIcons.Snap, "Snap To Grid",
			() => _panel is { SnapToGrid: true },
			() => { if ( _panel is not null ) _panel.SnapToGrid = !_panel.SnapToGrid; } );

		Toggle( PrismIcons.WireStyle, "Angular Wires",
			() => _panel?.GraphView is { WireStyle: PrismWireStyle.Orthogonal },
			() => _panel?.ToggleWireStyle() );

		Toggle( "map", "Minimap",
			() => _panel is { ShowMinimap: true },
			() => { if ( _panel is not null ) _panel.ShowMinimap = !_panel.ShowMinimap; } );

		Button( PrismIcons.Search, "Find Node  ·  Space", () => _panel?.OpenNodeSearch() );

		Refresh();
	}

	void Button( string icon, string tooltip, Action action ) =>
		_items.Add( new Item { Kind = ItemKind.Button, Icon = icon, Tooltip = tooltip, Action = action } );

	void Menu( string icon, string tooltip, Action action ) =>
		_items.Add( new Item { Kind = ItemKind.Menu, Icon = icon, Tooltip = tooltip, Action = action } );

	void Toggle( string icon, string tooltip, Func<bool> state, Action action ) =>
		_items.Add( new Item
		{
			Kind = ItemKind.Toggle,
			Icon = icon,
			Tooltip = tooltip,
			Action = action,
			State = state
		} );

	void Separator() => _items.Add( new Item { Kind = ItemKind.Separator } );

	void Readout() => _items.Add( new Item { Kind = ItemKind.Zoom, Tooltip = "Reset Zoom  ·  click" } );

	float MeasureWidth()
	{
		var width = Padding * 2f;

		foreach ( var item in _items ) width += WidthOf( item );

		return width;
	}

	static float WidthOf( Item item ) => item.Kind switch
	{
		ItemKind.Separator => SeparatorWidth,
		ItemKind.Zoom => ReadoutWidth,
		_ => ButtonSize
	};

	Rect RectOf( int index )
	{
		var x = Padding;

		for ( int i = 0; i < _items.Count; i++ )
		{
			var width = WidthOf( _items[i] );

			if ( i == index ) return new Rect( x, ( BarHeight - ButtonSize ) * 0.5f, width, ButtonSize );

			x += width;
		}

		return default;
	}

	// ---------------------------------------------------------------- layout ----

	/// <summary>Keep the toolbar pinned to the top-left of the canvas as it resizes.</summary>
	[EditorEvent.Frame]
	public void OnPrismFrame()
	{
		if ( !this.IsValid() || !Visible ) return;

		var parent = Parent;

		if ( !parent.IsValid() ) return;

		if ( parent.Size != _parentSize )
		{
			_parentSize = parent.Size;

			Position = new Vector2( Inset, Inset );
		}

		// The zoom readout and the toggles reflect state nobody notifies us about.
		SetContentHash( ContentHash(), 0.05f );
	}

	int ContentHash()
	{
		var zoom = _panel?.GraphView.IsValid() == true ? _panel.GraphView.Scale.x : 1f;
		var hash = HashCode.Combine( _hovered, _pressed, MathF.Round( zoom * 100f ) );

		foreach ( var item in _items )
		{
			if ( item.State is null ) continue;

			hash = HashCode.Combine( hash, PrismLog.Guard( "Read a toolbar toggle", item.State, false ) );
		}

		return hash;
	}

	// ---------------------------------------------------------------- painting ----

	/// <inheritdoc/>
	protected override void OnPaint()
	{
		var rect = LocalRect;

		Paint.Antialiasing = true;

		PrismPaint.DropShadow( rect, PrismTheme.RadiusPanel, PrismTheme.Shadow );

		Paint.SetBrushAndPen( PrismTheme.Elevated.WithAlpha( 0.94f ), PrismTheme.BorderStrong );
		Paint.DrawRect( rect, PrismTheme.RadiusPanel );

		for ( int i = 0; i < _items.Count; i++ )
		{
			var item = _items[i];
			var slot = RectOf( i );

			switch ( item.Kind )
			{
				case ItemKind.Separator:
					Paint.SetPen( PrismTheme.BorderSubtle, 1f );
					Paint.DrawLine(
						new Vector2( slot.Center.x, rect.Top + 7f ),
						new Vector2( slot.Center.x, rect.Bottom - 7f ) );
					continue;

				case ItemKind.Zoom:
					PaintZoom( slot, i );
					continue;

				default:
					PaintButton( slot, item, i );
					continue;
			}
		}
	}

	void PaintButton( Rect slot, Item item, int index )
	{
		var active = item.State is not null && PrismLog.Guard( "Read a toolbar toggle", item.State, false );
		var hot = _hovered == index;
		var down = _pressed == index;

		if ( active || hot || down )
		{
			var fill = down ? PrismTheme.Accent.WithAlpha( 0.32f )
				: active ? PrismTheme.AccentSoft
				: PrismTheme.BorderSubtle.WithAlpha( 0.85f );

			Paint.SetBrushAndPen( fill );
			Paint.DrawRect( slot.Shrink( 2f ), PrismTheme.RadiusChip );
		}

		var foreground = active ? PrismTheme.Accent
			: hot ? PrismTheme.TextPrimary
			: PrismTheme.TextSecondary;

		Paint.SetPen( foreground );
		Paint.DrawIcon( slot, item.Icon, IconSize );

		if ( item.Kind != ItemKind.Menu ) return;

		// A tiny corner wedge is the standard "this opens something" affordance and costs three points.
		Paint.SetBrushAndPen( foreground.WithAlpha( 0.75f ) );
		Paint.DrawPolygon(
			new Vector2( slot.Right - 4f, slot.Bottom - 6f ),
			new Vector2( slot.Right - 4f, slot.Bottom - 2f ),
			new Vector2( slot.Right - 8f, slot.Bottom - 2f ) );
	}

	void PaintZoom( Rect slot, int index )
	{
		var hot = _hovered == index;
		var zoom = _panel?.GraphView.IsValid() == true ? _panel.GraphView.Scale.x : 1f;

		if ( hot )
		{
			Paint.SetBrushAndPen( PrismTheme.BorderSubtle.WithAlpha( 0.85f ) );
			Paint.DrawRect( slot.Shrink( 2f ), PrismTheme.RadiusChip );
		}

		PrismPaint.Text( slot, $"{MathF.Round( zoom * 100f ):0}%",
			hot ? PrismTheme.TextPrimary : PrismTheme.TextSecondary,
			PrismTheme.InlineValueSize, PrismTheme.InlineValueWeight, TextFlag.Center );
	}

	// ---------------------------------------------------------------- interaction ----

	/// <inheritdoc/>
	protected override void OnMouseMove( MouseEvent e )
	{
		base.OnMouseMove( e );

		var index = IndexAt( e.LocalPosition );

		if ( index == _hovered ) return;

		_hovered = index;
		ToolTip = index >= 0 ? _items[index].Tooltip : null;

		Update();
	}

	/// <inheritdoc/>
	protected override void OnMouseLeave()
	{
		base.OnMouseLeave();

		_hovered = -1;
		_pressed = -1;

		Update();
	}

	/// <inheritdoc/>
	protected override void OnMousePress( MouseEvent e )
	{
		base.OnMousePress( e );

		if ( !e.LeftMouseButton ) return;

		_pressed = IndexAt( e.LocalPosition );

		e.Accepted = true;

		Update();
	}

	/// <inheritdoc/>
	protected override void OnMouseReleased( MouseEvent e )
	{
		base.OnMouseReleased( e );

		var index = IndexAt( e.LocalPosition );
		var pressed = _pressed;

		_pressed = -1;

		Update();

		if ( index < 0 || index != pressed ) return;

		var item = _items[index];

		if ( item.Kind == ItemKind.Zoom )
		{
			_panel?.ResetZoom();
			return;
		}

		if ( item.Action is null ) return;

		PrismLog.Guard( $"Overlay toolbar: {item.Tooltip}", item.Action );

		Refresh();
	}

	int IndexAt( Vector2 local )
	{
		for ( int i = 0; i < _items.Count; i++ )
		{
			if ( _items[i].Kind == ItemKind.Separator ) continue;

			if ( RectOf( i ).IsInside( local ) ) return i;
		}

		return -1;
	}

	void OpenAlignMenu()
	{
		var index = _items.FindIndex( x => x.Kind == ItemKind.Menu );
		var slot = index < 0 ? LocalRect : RectOf( index );
		var menu = new Menu( this ) { DeleteOnClose = true };

		menu.AddHeading( "Align" );
		menu.AddOption( "Left", "align_horizontal_left", () => _panel?.Align( AlignEdge.Left ) );
		menu.AddOption( "Centre", "align_horizontal_center", () => _panel?.Align( AlignEdge.CenterX ) );
		menu.AddOption( "Right", "align_horizontal_right", () => _panel?.Align( AlignEdge.Right ) );
		menu.AddSeparator();
		menu.AddOption( "Top", "align_vertical_top", () => _panel?.Align( AlignEdge.Top ) );
		menu.AddOption( "Middle", "align_vertical_center", () => _panel?.Align( AlignEdge.Middle ) );
		menu.AddOption( "Bottom", "align_vertical_bottom", () => _panel?.Align( AlignEdge.Bottom ) );

		menu.AddHeading( "Distribute" );
		menu.AddOption( "Horizontally", "horizontal_distribute", () => _panel?.Distribute( true ) );
		menu.AddOption( "Vertically", "vertical_distribute", () => _panel?.Distribute( false ) );

		menu.AddSeparator();
		menu.AddOption( "Snap Selection To Grid", PrismIcons.Snap, () => _panel?.SnapSelectionToGrid() );

		menu.OpenAt( ToScreen( new Vector2( slot.Left, slot.Bottom + 4f ) ), false );
	}

	enum ItemKind
	{
		Button,
		Toggle,
		Menu,
		Separator,
		Zoom
	}

	sealed class Item
	{
		public ItemKind Kind;
		public string Icon;
		public string Tooltip;
		public Action Action;
		public Func<bool> State;
	}
}