Editor/Prism/Preview/PreviewPanel.cs

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.

File AccessNetworking
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 );
	}
}