Editor UI code for the Prism preview dock and its subwidgets. PreviewPanel manages the preview viewport, material host, compile service wiring, thumbnail queuing and toolbar actions; PreviewChannelStrip renders and handles debug channel chips; PreviewStatusStrip shows compile state, timing and channel info.
using Editor.Prism.Compiler;
using Editor.Prism.Core;
using Editor.Prism.Model;
using Editor.Prism.Toolchain;
using Editor.Prism.Ui;
using Editor.Prism.Undo;
using EngineMaterial = Sandbox.Material;
using EngineModel = Sandbox.Model;
namespace Editor.Prism.Preview;
/// <summary>
/// The Preview dock: a toolbar, the 3D viewport, the debug-channel strip and a status line.
/// <para>
/// The viewport is a native window with Qt painting suppressed, so nothing can be drawn on top of it
/// with <c>Paint</c>. Every piece of chrome here is therefore a sibling widget stacked around it
/// rather than an overlay — which is also why the channel strip is a real, clickable strip instead of
/// a floating HUD.
/// </para>
/// <para>
/// This is the only place the preview meets the rest of the editor. It consumes the compile service
/// through its frozen contract, turns a successful compile into a live material, pushes the compile's
/// uniforms onto the subject, and feeds the thumbnail service. Everything below it — scene, meshes,
/// material host, attribute bus — is Qt-free and independently testable.
/// </para>
/// </summary>
public sealed class PreviewPanel : Widget
{
readonly PreviewViewport _viewport;
readonly PreviewChannelStrip _channels;
readonly PreviewStatusStrip _status;
readonly PreviewMaterialHost _materialHost;
readonly Dictionary<string, Option> _meshOptions = new( StringComparer.OrdinalIgnoreCase );
ToolBar _toolbar;
Option _nodePreviewOption;
Option _thumbnailOption;
Option _autoRotateOption;
Option _pauseOption;
ShaderCompileService _compiler;
IReadOnlyDictionary<NodeId, int> _stageIds = new Dictionary<NodeId, int>();
NodeId _focus;
EngineMaterial _backdrop;
bool _applying;
/// <summary>Build the panel. It is usable immediately, showing the fallback material on a sphere.</summary>
public PreviewPanel( Widget parent = null ) : base( parent )
{
Name = "PrismPreview";
WindowTitle = "Preview";
SetWindowIcon( "visibility" );
Settings = new PreviewSettings();
Thumbnails = new NodeThumbnailService();
Layout = Layout.Column();
_toolbar = BuildToolBar();
Layout.Add( _toolbar );
_viewport = new PreviewViewport( this );
Layout.Add( _viewport, 1 );
_channels = new PreviewChannelStrip( this )
{
ChannelPicked = OnChannelPicked
};
Layout.Add( _channels );
_status = new PreviewStatusStrip( this );
Layout.Add( _status );
_materialHost = new PreviewMaterialHost();
_materialHost.MaterialChanged += OnMaterialChanged;
_materialHost.Failed += OnMaterialFailed;
_viewport.CameraMoved += OnCameraMoved;
Thumbnails.ThumbnailReady += node => ThumbnailReady?.Invoke( node );
Settings.Changed += ApplySettings;
ApplySettings();
_materialHost.UseFallback();
}
/// <summary>The 3D viewport.</summary>
public PreviewViewport Viewport => _viewport;
/// <summary>Per-graph and per-user preview settings.</summary>
public PreviewSettings Settings { get; }
/// <summary>The per-node thumbnail renderer. Ask it for a node's <c>Pixmap</c>.</summary>
public NodeThumbnailService Thumbnails { get; }
/// <summary>Owns the compiled material and the reload dance behind it.</summary>
public PreviewMaterialHost MaterialHost => _materialHost;
/// <summary>The document being previewed.</summary>
public PrismGraph Graph { get; private set; }
/// <summary>The most recent compile result, successful or not.</summary>
public CompileResult LastResult { get; private set; }
/// <summary>Raised when a node's thumbnail has been produced, so the graph view can repaint it.</summary>
public event Action<NodeId> ThumbnailReady;
/// <summary>
/// The compile service the preview listens to. Assigning re-subscribes; assigning null detaches
/// cleanly, which is what a closing document does.
/// </summary>
public ShaderCompileService CompileService
{
get => _compiler;
set
{
if ( ReferenceEquals( _compiler, value ) ) return;
if ( _compiler is not null )
{
_compiler.Started -= OnCompileStarted;
_compiler.Completed -= OnCompileCompleted;
}
_compiler = value;
// The strip needs the service directly: it reconciles a stuck spinner against IsCompiling,
// because a cancelled compile raises Started and never Completed.
if ( _status.IsValid() ) _status.Service = value;
if ( _compiler is null )
{
// Nothing is reloading on our behalf any more, so the host goes back to doing it itself.
_materialHost.ReloadBeforeCreate = true;
return;
}
// Exactly one mat_reloadshaders per compile: the service's, which runs after the asset
// system reports the file compiled and up to date, rather than the host's blind one.
_materialHost.ReloadBeforeCreate = !_compiler.ReloadShaders;
_compiler.Started += OnCompileStarted;
_compiler.Completed += OnCompileCompleted;
if ( _compiler.Last is not null ) OnCompileCompleted( _compiler.Last );
}
}
// ---- binding -----------------------------------------------------------
/// <summary>Point the preview at a document and its compile service.</summary>
public void Bind( PrismGraph graph, GraphMutations mutations, ShaderCompileService compiler )
{
Graph = graph;
Settings.Bind( graph, mutations );
CompileService = compiler;
_stageIds = new Dictionary<NodeId, int>();
_focus = NodeId.None;
Thumbnails.Clear();
_viewport.Attributes.Clear();
ApplySettings();
Refresh();
}
/// <summary>Ask the compile service for a fresh preview build. Debounced and coalesced by the service.</summary>
public void Refresh()
{
if ( Graph is null || _compiler is null ) return;
PrismLog.Guard( "Requesting a preview compile", () => _compiler.Request( Graph, CompileMode.Preview ) );
}
/// <summary>
/// Focus a node: the viewport shows that node's value instead of the shader's own output, if node
/// preview is on. One attribute write, zero compiles.
/// </summary>
public void SetFocusNode( NodeId node )
{
_focus = node;
UpdateStage();
}
/// <summary>The cached thumbnail for a node, or null when there is not one.</summary>
public Pixmap Thumbnail( NodeId node ) => Thumbnails.Get( node );
/// <summary>Mark a node's thumbnail stale, e.g. after the user edited it.</summary>
public void InvalidateThumbnail( NodeId node ) => Thumbnails.Invalidate( node );
/// <summary>Tell the thumbnail service which nodes are on screen, so those render first.</summary>
public void PrioritiseThumbnails( IEnumerable<NodeId> visible ) => Thumbnails.Prioritise( visible );
/// <summary>
/// Re-scan the graph for nodes asking to be previewed, queue the ones that are new and release the
/// images of the ones that no longer are. Call after the user toggles a node's preview flag.
/// </summary>
public void RefreshThumbnails() => QueueThumbnails();
/// <inheritdoc/>
public override void OnDestroyed()
{
base.OnDestroyed();
CompileService = null;
Settings.Changed -= ApplySettings;
Settings.Save();
if ( _materialHost is not null )
{
_materialHost.MaterialChanged -= OnMaterialChanged;
_materialHost.Failed -= OnMaterialFailed;
_materialHost.Dispose();
}
Thumbnails?.Dispose();
}
// ---- compile plumbing --------------------------------------------------
void OnCompileStarted()
{
MainThread.Queue( () =>
{
if ( !this.IsValid() ) return;
_status.SetCompiling();
} );
}
void OnCompileCompleted( CompileResult result )
{
MainThread.Queue( () =>
{
if ( !this.IsValid() ) return;
LastResult = result;
_status.SetResult( result );
if ( result is null || !result.Ok )
{
_materialHost.UseFallback();
return;
}
// Live uniforms first: they must already be on the object when the new material lands, or
// the first frame of the new shader renders with everything at zero.
_viewport.Attributes.Apply( result.PreviewAttributes );
// Textures too. A preview shader binds its slots to attributes rather than declaring
// CreateInputTexture slots, because nothing compiles a material to bake those — leave them
// unpushed and every sampler reads black.
_viewport.Attributes.ApplyTextures( result.PreviewTextures );
_stageIds = NodeThumbnailService.BuildStageMap( result );
UpdateStage();
// The service exposes the path twice: as written to disk, and as the runtime resolves it.
// Material.Create wants the second, and PreviewMaterialHost.Normalize derives it from the
// first, so either one lands on the same string.
var path = _compiler?.PreviewShaderPath;
if ( string.IsNullOrWhiteSpace( path ) ) path = _compiler?.LastShaderPath;
if ( string.IsNullOrWhiteSpace( path ) )
{
_materialHost.UseFallback();
return;
}
_materialHost.Apply( path );
} );
}
void OnMaterialChanged( EngineMaterial material )
{
if ( !this.IsValid() ) return;
var postProcess = Domain == ShaderDomain.PostProcess;
var generated = _materialHost.HasGeneratedMaterial;
PrismLog.Guard( "Applying the preview material", () =>
{
if ( postProcess && generated )
{
// A post-process shader cannot be drawn on the subject: it wants a full-screen quad in
// clip space, so it goes through a command list and the subject becomes a backdrop.
_viewport.PreviewScene.Material = Backdrop;
_viewport.PostProcess.Material = material;
_viewport.PostProcess.Enabled = true;
}
else
{
_viewport.PostProcess.Enabled = false;
_viewport.PostProcess.Material = null;
_viewport.PreviewScene.Material = material;
}
} );
Thumbnails.SetSource( postProcess ? null : material, LastResult );
QueueThumbnails();
}
void OnMaterialFailed( string path )
{
if ( !this.IsValid() ) return;
_status.SetMessage( $"Could not load {path}", DiagnosticSeverity.Error );
}
void UpdateStage()
{
_viewport.StageId = _focus.IsValid && _stageIds is not null && _stageIds.TryGetValue( _focus, out var id )
? id
: 0;
}
/// <summary>
/// Ask for a thumbnail for every node that has actually asked for one.
/// <para>
/// Only nodes carrying <see cref="NodeFlags.Preview"/> are rendered. Requesting one per node in the
/// document — which is what this used to do — meant a 265-node graph queued 265 GPU readbacks after
/// every compile to produce images that no card would ever draw, and it made the per-node preview
/// toggle meaningless. <see cref="NodeThumbnailService.Retain"/> then releases the cached images of
/// nodes that have been deleted or had their preview turned back off.
/// </para>
/// </summary>
void QueueThumbnails()
{
if ( Graph is null ) return;
if ( !Settings.NodeThumbnails )
{
Thumbnails.Enabled = false;
Thumbnails.Retain( Array.Empty<NodeId>() );
return;
}
Thumbnails.Enabled = true;
PrismLog.Guard( "Queueing node thumbnails", () =>
{
var wanted = PreviewedNodes();
Thumbnails.Retain( wanted );
Thumbnails.RequestFor( Graph, wanted );
} );
}
/// <summary>The ids of every node whose card is asking to draw a thumbnail.</summary>
IReadOnlyList<NodeId> PreviewedNodes()
{
var nodes = Graph?.Nodes;
if ( nodes is null ) return Array.Empty<NodeId>();
var wanted = new List<NodeId>();
foreach ( var node in nodes )
{
if ( node is null || ( node.Flags & NodeFlags.Preview ) == 0 ) continue;
wanted.Add( node.Id );
}
return wanted;
}
ShaderDomain Domain => Graph?.Settings?.Domain ?? ShaderDomain.Surface;
EngineMaterial Backdrop => _backdrop ??= PrismLog.Guard( "Loading the preview backdrop",
() => EngineMaterial.Load( PreviewMaterialHost.BackdropMaterial ), null ) ?? _materialHost.Fallback;
// ---- settings ----------------------------------------------------------
void ApplySettings()
{
if ( _applying ) return;
_applying = true;
try
{
Settings.ApplyTo( _viewport );
var thumbnails = Settings.NodeThumbnails;
var toggled = thumbnails != Thumbnails.Enabled;
Thumbnails.Enabled = thumbnails;
// Mirrored onto the card painter so a node with its preview flag set can say why its
// reserved box is empty rather than sitting there looking broken.
Ui.PrismNodeUi.ThumbnailsEnabled = thumbnails;
_channels.Channel = Settings.Channel;
_status.Channel = Settings.Channel;
UpdateToolBarState();
// Only re-scan the graph when thumbnails were switched on or off. Every other settings change
// — including a camera commit — must not walk every node's dependency subtree.
if ( toggled ) QueueThumbnails();
}
finally
{
_applying = false;
}
}
void OnCameraMoved()
{
if ( _applying ) return;
Settings.CommitCamera( _viewport.Yaw, _viewport.Pitch, _viewport.Distance );
}
void OnChannelPicked( PreviewChannel channel )
{
Settings.Channel = channel;
_status.Channel = channel;
_viewport.Channel = channel;
}
// ---- toolbar -----------------------------------------------------------
ToolBar BuildToolBar()
{
var bar = new ToolBar( this, "PrismPreviewToolBar" );
bar.SetIconSize( 16 );
foreach ( var mesh in PreviewMeshes.Names )
{
var name = mesh;
var option = bar.AddOption( null, PreviewMeshes.IconFor( name ), () => PickMesh( name ) );
option.Checkable = true;
option.ToolTip = name;
option.StatusTip = $"Preview on a {name.ToLowerInvariant()}";
_meshOptions[name] = option;
}
var custom = bar.AddOption( null, "category", PickModel );
custom.ToolTip = "Custom Model…";
custom.StatusTip = "Preview on any model asset";
bar.AddSeparator();
var frame = bar.AddOption( null, "filter_center_focus", () => _viewport.FrameSubject() );
frame.ToolTip = "Frame (F)";
frame.StatusTip = "Frame the preview mesh";
_autoRotateOption = bar.AddOption( null, "rotate_right" );
_autoRotateOption.Checkable = true;
_autoRotateOption.ToolTip = "Auto Rotate";
_autoRotateOption.StatusTip = "Spin the mesh so reflections read";
_autoRotateOption.Toggled = value => Settings.AutoRotate = value;
_pauseOption = bar.AddOption( null, "pause" );
_pauseOption.Checkable = true;
_pauseOption.ToolTip = "Pause Time";
_pauseOption.StatusTip = "Freeze the preview clock";
_pauseOption.Toggled = value => _viewport.Paused = value;
bar.AddSeparator();
_nodePreviewOption = bar.AddOption( null, "preview" );
_nodePreviewOption.Checkable = true;
_nodePreviewOption.ToolTip = "Node Preview";
_nodePreviewOption.StatusTip = "Show the selected node's value instead of the shader output";
_nodePreviewOption.Toggled = value => Settings.NodePreview = value;
_thumbnailOption = bar.AddOption( null, "grid_view" );
_thumbnailOption.Checkable = true;
_thumbnailOption.ToolTip = "Node Thumbnails";
_thumbnailOption.StatusTip = "Render a small preview onto every node";
_thumbnailOption.Toggled = value => Settings.NodeThumbnails = value;
bar.AddSeparator();
var lights = bar.AddOption( null, "lightbulb", OpenLightSettings );
lights.ToolTip = "Lighting…";
lights.StatusTip = "Key light, fill lights, shadows and exposure";
var settings = bar.AddOption( null, "settings", OpenSettings );
settings.ToolTip = "Preview Settings…";
settings.StatusTip = "Mesh, environment, background and tint";
return bar;
}
void UpdateToolBarState()
{
var mesh = Settings.Mesh;
var custom = !string.IsNullOrWhiteSpace( Settings.ModelPath );
foreach ( var (name, option) in _meshOptions )
{
if ( option is null ) continue;
option.Checked = !custom && string.Equals( name, mesh, StringComparison.OrdinalIgnoreCase );
}
if ( _autoRotateOption is not null ) _autoRotateOption.Checked = Settings.AutoRotate;
if ( _nodePreviewOption is not null ) _nodePreviewOption.Checked = Settings.NodePreview;
if ( _thumbnailOption is not null ) _thumbnailOption.Checked = Settings.NodeThumbnails;
if ( _pauseOption is not null ) _pauseOption.Checked = _viewport.Paused;
}
void PickMesh( string mesh )
{
Settings.ModelPath = null;
Settings.Mesh = mesh;
}
void PickModel()
{
PrismLog.Guard( "Opening the model picker", () =>
{
var picker = AssetPicker.Create( this, AssetType.Model );
picker.OnAssetHighlighted = assets => UseModelAsset( assets?.FirstOrDefault() );
picker.OnAssetPicked = assets => UseModelAsset( assets?.FirstOrDefault() );
picker.Window.Show();
} );
}
void UseModelAsset( Asset asset )
{
if ( asset is null ) return;
PrismLog.Guard( "Using a custom preview model", () =>
{
var model = asset.LoadResource<EngineModel>();
if ( model is null ) return;
Settings.ModelPath = asset.Path;
_viewport.SetModel( model );
} );
}
void OpenLightSettings()
{
PrismLog.Guard( "Opening preview lighting settings", () =>
{
var popup = new PopupWidget( this )
{
IsPopup = true,
Layout = Layout.Column()
};
popup.Layout.Margin = 16;
var sheet = new ControlSheet();
sheet.AddProperty( Settings, x => x.SunAngle );
sheet.AddProperty( Settings, x => x.SunColor );
sheet.AddProperty( Settings, x => x.SunBrightness );
sheet.AddProperty( Settings, x => x.AmbientStrength );
sheet.AddProperty( Settings, x => x.EnableFillLights );
sheet.AddProperty( Settings, x => x.EnableShadows );
sheet.AddProperty( Settings, x => x.EnableTonemapping );
popup.Layout.Add( sheet );
popup.MaximumWidth = 320;
popup.OpenAtCursor();
} );
}
void OpenSettings()
{
PrismLog.Guard( "Opening preview settings", () =>
{
var popup = new PopupWidget( this )
{
IsPopup = true,
Layout = Layout.Column()
};
popup.Layout.Margin = 16;
var sheet = new ControlSheet();
sheet.AddProperty( Settings, x => x.Envmap );
sheet.AddProperty( Settings, x => x.ShowSkybox );
sheet.AddProperty( Settings, x => x.ShowGround );
sheet.AddProperty( Settings, x => x.Background );
sheet.AddProperty( Settings, x => x.Tint );
sheet.AddProperty( Settings, x => x.RenderBackfaces );
sheet.AddProperty( Settings, x => x.FieldOfView );
sheet.AddProperty( Settings, x => x.RotateSpeed );
popup.Layout.Add( sheet );
popup.MaximumWidth = 320;
popup.OpenAtCursor();
} );
}
}
/// <summary>
/// The debug-channel strip: one clickable chip per <see cref="PreviewChannel"/>.
/// <para>
/// Painted rather than assembled from child widgets, because it has to collapse to icons when the
/// dock is narrow and scroll when even that does not fit — neither of which a layout does well, and
/// both of which are three lines here.
/// </para>
/// </summary>
public sealed class PreviewChannelStrip : Widget
{
readonly List<Rect> _chips = new();
// Reused rather than allocated per paint: the channel list has a fixed length for the life of the
// editor, and this method runs on every repaint of the strip.
readonly float[] _widths = new float[PreviewChannels.All.Count];
float _scroll;
int _hovered = -1;
bool _iconsOnly;
/// <summary>Build the strip.</summary>
public PreviewChannelStrip( Widget parent = null ) : base( parent )
{
FixedHeight = 28f;
MouseTracking = true;
Cursor = CursorShape.Finger;
ToolTip = "Debug channels";
}
/// <summary>The selected channel.</summary>
public PreviewChannel Channel { get; set; } = PreviewChannel.Final;
/// <summary>Raised when the user picks a channel.</summary>
public Action<PreviewChannel> ChannelPicked { get; set; }
/// <inheritdoc/>
protected override void OnPaint()
{
Paint.Antialiasing = true;
Paint.ClearPen();
Paint.SetBrush( PrismTheme.PanelAlt );
Paint.DrawRect( LocalRect );
Paint.SetPen( PrismTheme.BorderSubtle, 1f );
Paint.DrawLine( new Vector2( 0, 0.5f ), new Vector2( Width, 0.5f ) );
LayoutChips();
for ( int i = 0; i < PreviewChannels.All.Count && i < _chips.Count; i++ )
{
PaintChip( PreviewChannels.All[i], _chips[i], i == _hovered );
}
}
void PaintChip( PreviewChannelInfo info, Rect rect, bool hovered )
{
if ( rect.Right < 0 || rect.Left > Width ) return;
var selected = info.Channel == Channel;
var accent = info.Wireframe || info.DebugMode is not null ? PrismTheme.Accent : PrismTheme.Accent2;
Paint.ClearPen();
if ( selected )
{
Paint.SetBrush( accent.WithAlpha( 0.22f ) );
Paint.DrawRect( rect, PrismTheme.RadiusChip );
Paint.SetPen( accent, 1f );
Paint.ClearBrush();
Paint.DrawRect( rect, PrismTheme.RadiusChip );
}
else if ( hovered )
{
Paint.SetBrush( PrismTheme.Elevated );
Paint.DrawRect( rect, PrismTheme.RadiusChip );
}
var foreground = selected
? PrismTheme.TextPrimary
: info.NeedsShaderSupport ? PrismTheme.TextMuted : PrismTheme.TextSecondary;
Paint.SetPen( foreground );
var iconRect = new Rect( rect.Left + 6f, rect.Top, 14f, rect.Height );
Paint.DrawIcon( iconRect, info.Icon, 14f, TextFlag.Center );
if ( _iconsOnly ) return;
Paint.SetFont( PrismTheme.FontFamily, PrismTheme.PortLabelSize, PrismTheme.PortLabelWeight, false, true );
Paint.SetPen( foreground );
var textRect = new Rect( iconRect.Right + 4f, rect.Top, rect.Width - 26f, rect.Height );
Paint.DrawText( textRect, info.Title, TextFlag.LeftCenter );
}
void LayoutChips()
{
_chips.Clear();
Paint.SetFont( PrismTheme.FontFamily, PrismTheme.PortLabelSize, PrismTheme.PortLabelWeight, false, true );
var total = 0f;
for ( int i = 0; i < _widths.Length; i++ )
{
var text = Paint.MeasureText( PreviewChannels.All[i].Title );
_widths[i] = 6f + 14f + 4f + text.x + 8f;
total += _widths[i] + 2f;
}
_iconsOnly = total > Width;
var x = -_scroll + 4f;
for ( int i = 0; i < _widths.Length; i++ )
{
var width = _iconsOnly ? 26f : _widths[i];
_chips.Add( new Rect( x, 4f, width, Height - 8f ) );
x += width + 2f;
}
var extent = MathF.Max( 0f, x + _scroll - Width );
_scroll = Math.Clamp( _scroll, 0f, extent );
}
/// <inheritdoc/>
protected override void OnMouseMove( MouseEvent e )
{
base.OnMouseMove( e );
var hovered = IndexAt( e.LocalPosition );
if ( hovered == _hovered ) return;
_hovered = hovered;
ToolTip = hovered >= 0 && hovered < PreviewChannels.All.Count
? $"{PreviewChannels.All[hovered].Title} — {PreviewChannels.All[hovered].Description}"
: "Debug channels";
Update();
}
/// <inheritdoc/>
protected override void OnMouseLeave()
{
base.OnMouseLeave();
if ( _hovered < 0 ) return;
_hovered = -1;
Update();
}
/// <inheritdoc/>
protected override void OnMousePress( MouseEvent e )
{
base.OnMousePress( e );
if ( !e.LeftMouseButton ) return;
var index = IndexAt( e.LocalPosition );
if ( index < 0 || index >= PreviewChannels.All.Count ) return;
Channel = PreviewChannels.All[index].Channel;
Update();
ChannelPicked?.Invoke( Channel );
}
/// <inheritdoc/>
protected override void OnMouseWheel( WheelEvent e )
{
base.OnMouseWheel( e );
_scroll = MathF.Max( 0f, _scroll - e.Delta * 0.5f );
Update();
}
int IndexAt( Vector2 position )
{
for ( int i = 0; i < _chips.Count; i++ )
{
if ( _chips[i].IsInside( position ) ) return i;
}
return -1;
}
}
/// <summary>
/// The one-line status readout under the viewport: compile state, timing, how many live uniforms are
/// bound and which channel is showing. Small, but it is what tells you whether the thing you are
/// looking at is the shader you just wrote or the last one that compiled.
/// </summary>
public sealed class PreviewStatusStrip : Widget
{
string _message = "Ready";
DiagnosticSeverity _severity = DiagnosticSeverity.Info;
bool _compiling;
string _detail = string.Empty;
RealTimeSince _sinceCompiling;
/// <summary>Build the strip.</summary>
public PreviewStatusStrip( Widget parent = null ) : base( parent )
{
FixedHeight = 22f;
}
/// <summary>
/// The compile service whose state this strip mirrors, if any. Set by the panel that owns it.
/// <para>
/// The spinner is turned on by the <c>Started</c> event and off by <c>Completed</c>, and that pair
/// is not symmetric: a compile cancelled after it started — a debounce superseding it, a window
/// closing, an explicit Cancel — throws <c>OperationCanceledException</c> before it reaches
/// <c>Publish</c>, so no <c>Completed</c> ever arrives and the strip would spin forever. This is the
/// authoritative state, checked once a frame, so an unpaired Started self-corrects.
/// </para>
/// </summary>
public ShaderCompileService Service { get; set; }
/// <summary>
/// Pulse the state dot while a compile is in flight, so "the viewport is showing the last shader
/// that built" is visible rather than merely stated. Nothing is repainted when nothing is compiling:
/// the content hash only moves while <c>_compiling</c> is set.
/// </summary>
[EditorEvent.Frame]
public void OnPreviewStatusFrame()
{
if ( !this.IsValid() ) return;
// Reconcile against the service before anything else. See Service.
if ( _compiling && Service is not null && !Service.IsCompiling && _sinceCompiling > 0.5f )
{
SetSettled();
return;
}
if ( !_compiling ) return;
SetContentHash( (int)( _sinceCompiling * 6f ), 0.1f );
}
/// <summary>
/// Stop showing "compiling" without claiming a result. Used when a compile went away rather than
/// finished, which is what a cancellation is.
/// </summary>
public void SetSettled()
{
if ( !_compiling ) return;
_compiling = false;
_message = "Cancelled";
_detail = "showing the last shader that built";
_severity = DiagnosticSeverity.Info;
Update();
}
float Throb => _compiling ? 0.55f + 0.45f * ( 0.5f + 0.5f * MathF.Sin( _sinceCompiling * 5f ) ) : 1f;
/// <summary>The channel shown on the right-hand side.</summary>
public PreviewChannel Channel { get; set; } = PreviewChannel.Final;
/// <summary>Show the "compiling" state.</summary>
public void SetCompiling()
{
_compiling = true;
_sinceCompiling = 0f;
_message = "Compiling…";
_detail = "showing the last shader that built";
_severity = DiagnosticSeverity.Info;
Update();
}
/// <summary>Summarise a finished compile.</summary>
public void SetResult( CompileResult result )
{
_compiling = false;
if ( result is null )
{
_message = "No result";
_detail = string.Empty;
_severity = DiagnosticSeverity.Warning;
Update();
return;
}
var errors = result.ErrorCount;
var warnings = result.WarningCount;
if ( errors > 0 )
{
_message = errors == 1 ? "1 error" : $"{errors} errors";
_severity = DiagnosticSeverity.Error;
}
else if ( warnings > 0 )
{
_message = warnings == 1 ? "1 warning" : $"{warnings} warnings";
_severity = DiagnosticSeverity.Warning;
}
else
{
_message = "Up to date";
_severity = DiagnosticSeverity.Info;
}
var stats = result.Stats;
_detail = stats is null
? string.Empty
: $"{stats.NodeCount} nodes · {result.PreviewAttributes?.Count ?? 0} uniforms · {stats.TotalMs:0.#} ms";
Update();
}
/// <summary>Show an arbitrary message, e.g. a material that could not be loaded.</summary>
public void SetMessage( string message, DiagnosticSeverity severity = DiagnosticSeverity.Info )
{
_compiling = false;
_message = message ?? string.Empty;
_detail = string.Empty;
_severity = severity;
Update();
}
/// <inheritdoc/>
protected override void OnPaint()
{
Paint.Antialiasing = true;
Paint.ClearPen();
Paint.SetBrush( PrismTheme.Panel );
Paint.DrawRect( LocalRect );
Paint.SetPen( PrismTheme.BorderSubtle, 1f );
Paint.DrawLine( new Vector2( 0, 0.5f ), new Vector2( Width, 0.5f ) );
var colour = _compiling ? PrismTheme.Accent : PrismTheme.ForSeverity( _severity );
Paint.ClearPen();
Paint.SetBrush( colour.WithAlpha( Throb ) );
Paint.DrawCircle( new Vector2( 12f, Height * 0.5f ), new Vector2( 6f, 6f ) );
Paint.SetFont( PrismTheme.FontFamily, PrismTheme.PortLabelSize, PrismTheme.PortLabelWeight, false, true );
Paint.SetPen( _compiling ? PrismTheme.TextSecondary : PrismTheme.TextPrimary );
Paint.DrawText( new Rect( 24f, 0f, Width - 24f, Height ), _message, TextFlag.LeftCenter );
var offset = Paint.MeasureText( _message ).x + 34f;
var available = Width - offset - 96f;
if ( !string.IsNullOrEmpty( _detail ) && available > 24f )
{
Paint.SetPen( PrismTheme.TextMuted );
Paint.DrawText( new Rect( offset, 0f, available, Height ), _detail, TextFlag.LeftCenter );
}
Paint.SetPen( PrismTheme.TextMuted );
Paint.DrawText( new Rect( 0f, 0f, Width - 10f, Height ), PreviewChannels.Title( Channel ), TextFlag.RightCenter );
}
}