An Editor UI panel showing the undo/redo history for a Prism document. It builds a list of HistoryRow entries from PrismUndoStack, paints rows with icons and timestamps, and allows clicking, context menu actions (jump, undo, redo, clear, copy dump). It binds to a PrismSession and listens for undo stack changes to rebuild the view.
using Editor.Prism.Core;
using Editor.Prism.Undo;
using Margin = Sandbox.UI.Margin;
namespace Editor.Prism.Ui;
/// <summary>One row of the History panel: a level in the undo stack.</summary>
internal sealed class HistoryRow
{
/// <summary>The stack level this row jumps to. Zero is the document as opened.</summary>
public int Level { get; init; }
/// <summary>The label of the edit.</summary>
public string Name { get; init; }
/// <summary>True when this is where the document currently sits.</summary>
public bool IsCurrent { get; init; }
/// <summary>True when this row is ahead of the current level, i.e. redoable.</summary>
public bool IsFuture { get; init; }
/// <summary>When the edit was committed.</summary>
public DateTime Time { get; init; }
/// <summary>How many characters the snapshot pair costs.</summary>
public int Size { get; init; }
/// <inheritdoc/>
public override string ToString() => $"{Level}. {Name}";
}
/// <summary>
/// The History dock: the undo stack, as a list you can click.
/// <para>
/// Prism records snapshots rather than commands, so every level is a complete, valid document and
/// jumping to any of them is exactly as safe as jumping to the one next door. That makes a clickable
/// history honest rather than a trap — which is why it gets a panel instead of two toolbar arrows.
/// </para>
/// </summary>
public sealed class HistoryPanel : Widget
{
/// <summary>The dock name this panel registers under. Frozen.</summary>
public const string DockName = "History";
readonly List<HistoryRow> _rows = new();
PrismSession _session;
PrismUndoStack _undo;
ListView _list;
PrismEmptyState _empty;
Label _status;
bool _rebuildQueued;
/// <summary>Build the panel. A null session is legal and shows the empty state.</summary>
public HistoryPanel( PrismSession session ) : base( null )
{
Name = "PrismHistory";
WindowTitle = DockName;
Layout = Layout.Column();
Layout.Margin = 0;
Layout.Spacing = 0;
_list = new ListView( this )
{
ItemSize = new Vector2( -1, PrismPanelChrome.RowHeight ),
ItemPaint = PaintRow,
ItemClicked = OnRowClicked,
ItemActivated = OnRowClicked,
ItemContextMenu = OnRowContextMenu,
MultiSelect = false
};
Layout.Add( _list, 1 );
_empty = new PrismEmptyState( this, "history", "Nothing to undo yet",
"Every edit you make appears here. Click one to jump back to it." );
Layout.Add( _empty, 1 );
_status = new Label( string.Empty ) { Color = PrismTheme.TextMuted };
_status.ContentMargins = new Margin( 8, 2, 8, 4 );
Layout.Add( _status );
Bind( session );
}
/// <summary>Material icon shown on the dock tab.</summary>
public string DockIcon => "history";
/// <summary>The session this panel is bound to. Null is legal.</summary>
public PrismSession Session => _session;
// ---------------------------------------------------------------- binding ----
void Bind( PrismSession session )
{
Unbind();
_session = session;
if ( _session is not null )
{
_session.DocumentReplaced += OnDocumentReplaced;
Attach( _session.Undo );
}
Rebuild();
}
void Attach( PrismUndoStack undo )
{
if ( ReferenceEquals( _undo, undo ) ) return;
if ( _undo is not null )
{
_undo.Changed -= QueueRebuild;
_undo.Restored -= QueueRebuild;
}
_undo = undo;
if ( _undo is null ) return;
_undo.Changed += QueueRebuild;
_undo.Restored += QueueRebuild;
}
void Unbind()
{
Attach( null );
if ( _session is null ) return;
_session.DocumentReplaced -= OnDocumentReplaced;
_session = null;
}
/// <inheritdoc/>
public override void OnDestroyed()
{
Unbind();
base.OnDestroyed();
}
void OnDocumentReplaced()
{
Attach( _session?.Undo );
QueueRebuild();
}
void QueueRebuild()
{
if ( _rebuildQueued ) return;
_rebuildQueued = true;
MainThread.Queue( () =>
{
_rebuildQueued = false;
if ( !this.IsValid() ) return;
Rebuild();
} );
}
// ---------------------------------------------------------------- model ----
void Rebuild()
{
_rows.Clear();
if ( _undo is null )
{
_empty.Set( "No document", "Open a graph to see its edit history." );
_empty.Visible = true;
_list.Visible = false;
_status.Text = string.Empty;
_list.SetItems( _rows );
return;
}
var level = _undo.Level;
foreach ( var item in _undo.History )
{
_rows.Add( new HistoryRow
{
Level = item.Level,
Name = item.Name,
IsCurrent = item.IsCurrent,
IsFuture = item.Level > level,
Time = item.Time,
Size = item.Size
} );
}
var meaningful = _undo.Count > 0;
_empty.Visible = !meaningful;
_list.Visible = meaningful;
if ( !meaningful )
{
_empty.Set( "Nothing to undo yet", "Every edit you make appears here. Click one to jump back to it." );
}
_list.SetItems( _rows );
var current = _rows.FirstOrDefault( x => x.IsCurrent );
if ( current is not null )
{
_list.SelectItem( current, false, true );
PrismLog.Guard( "History: scroll to current", () => _list.ScrollTo( current ) );
}
_status.Text = _undo.Describe();
}
// ---------------------------------------------------------------- painting ----
void PaintRow( VirtualWidget item )
{
if ( item.Object is not HistoryRow row ) return;
var rect = item.Rect;
var index = _rows.IndexOf( row );
PrismPanelChrome.PaintRow( rect, index, item.Hovered, row.IsCurrent );
var alpha = row.IsFuture ? 0.42f : 1f;
var inner = rect.Shrink( PrismPanelChrome.Pad, 0f, PrismPanelChrome.Pad, 0f );
var icon = IconFor( row );
var color = row.IsCurrent ? PrismTheme.Accent : PrismTheme.TextMuted;
Paint.SetPen( color.WithAlpha( alpha ) );
Paint.DrawIcon( new Rect( inner.Left, inner.Top, 16f, inner.Height ), icon, 13f, TextFlag.Center );
var right = inner.Right;
if ( inner.Width > 170f )
{
var time = row.Time == default ? string.Empty : row.Time.ToLocalTime().ToString( "HH:mm:ss" );
if ( !string.IsNullOrEmpty( time ) )
{
Paint.SetFont( PrismTheme.FontFamily, 10, 400, false, true );
Paint.SetPen( PrismTheme.TextDisabled.WithAlpha( alpha ) );
Paint.DrawText( new Rect( right - 52f, inner.Top, 52f, inner.Height ), time,
TextFlag.RightCenter | TextFlag.SingleLine );
right -= 58f;
}
}
if ( row.IsCurrent && inner.Width > 220f )
{
Paint.SetFont( PrismTheme.FontFamily, 9, 600, false, true );
Paint.SetPen( PrismTheme.Accent );
Paint.DrawText( new Rect( right - 44f, inner.Top, 44f, inner.Height ), "CURRENT",
TextFlag.RightCenter | TextFlag.SingleLine );
right -= 50f;
}
var nameRect = new Rect( inner.Left + 20f, inner.Top,
MathF.Max( 20f, right - inner.Left - 20f ), inner.Height );
PrismPaint.Text( nameRect, row.Name,
( row.IsCurrent ? PrismTheme.TextPrimary : PrismTheme.TextSecondary ).WithAlpha( alpha ),
PrismTheme.BodySize, row.IsCurrent ? 500 : 400 );
}
/// <summary>
/// The glyph for an edit, matched on its label. Undo entries are labelled by the mutation API from
/// a small, stable vocabulary, so a prefix match is reliable and one unknown label degrades to a
/// generic pencil rather than a blank row.
/// </summary>
static string IconFor( HistoryRow row )
{
if ( row.Level == 0 ) return PrismIcons.Open;
var name = row.Name ?? string.Empty;
if ( name.StartsWith( "Add", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Add;
if ( name.StartsWith( "Delete", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Delete;
if ( name.StartsWith( "Move", StringComparison.OrdinalIgnoreCase ) ) return "open_with";
if ( name.StartsWith( "Resize", StringComparison.OrdinalIgnoreCase ) ) return "aspect_ratio";
if ( name.StartsWith( "Create Connection", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Connect;
if ( name.StartsWith( "Disconnect", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Disconnect;
if ( name.StartsWith( "Reroute", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Reroute;
if ( name.StartsWith( "Route", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Reroute;
if ( name.StartsWith( "Paste", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Paste;
if ( name.StartsWith( "Cut", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Cut;
if ( name.StartsWith( "Duplicate", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Duplicate;
if ( name.StartsWith( "Rename", StringComparison.OrdinalIgnoreCase ) ) return "edit";
if ( name.StartsWith( "Reorder", StringComparison.OrdinalIgnoreCase ) ) return "swap_vert";
if ( name.StartsWith( "Change Settings", StringComparison.OrdinalIgnoreCase ) ) return "settings";
if ( name.StartsWith( "Change Preview", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Preview;
if ( name.StartsWith( "Set", StringComparison.OrdinalIgnoreCase ) ) return PrismIcons.Parameter;
if ( name.StartsWith( "Edit", StringComparison.OrdinalIgnoreCase ) ) return "edit";
return "edit_note";
}
/// <inheritdoc/>
protected override void OnPaint()
{
Paint.ClearPen();
Paint.SetBrush( PrismTheme.Panel );
Paint.DrawRect( LocalRect );
}
// ---------------------------------------------------------------- interaction ----
void OnRowClicked( object item )
{
if ( item is not HistoryRow row || _undo is null ) return;
if ( row.IsCurrent ) return;
if ( !_undo.JumpTo( row.Level ) ) return;
_session?.MarkDirty();
_session?.Touch();
}
void OnRowContextMenu( object item )
{
var menu = new Menu( this );
if ( item is HistoryRow row && !row.IsCurrent )
{
menu.AddOption( $"Jump To “{row.Name}”", "history", () => OnRowClicked( row ) );
menu.AddSeparator();
}
menu.AddOption( "Undo", PrismIcons.Undo, () => { _undo?.Undo(); _session?.Touch(); } )
.Enabled = _undo is { CanUndo: true };
menu.AddOption( "Redo", PrismIcons.Redo, () => { _undo?.Redo(); _session?.Touch(); } )
.Enabled = _undo is { CanRedo: true };
menu.AddSeparator();
menu.AddOption( "Clear History", PrismIcons.Delete, () => { _undo?.Clear(); Rebuild(); } );
menu.AddOption( "Copy Stack Dump", PrismIcons.Copy,
() => EditorUtility.Clipboard.Copy( _undo?.Dump() ?? string.Empty ) );
menu.OpenAtCursor( false );
}
}