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.
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;
}
}