Editor/Prism/Ui/PrismConnection.cs

UI component for an editor wire (connection) in the Prism node graph. Draws the wire with themed colours, halo, endpoints, loss/dash/marker visuals, shows a tooltip describing source/target types and conversion, and handles gestures: double-click to insert a reroute, Ctrl+drag to duplicate (start a new drag from the source) while preserving normal drag semantics.

Native Interop
using Editor.Prism.Core;
using Editor.Prism.Model;
using Editor.Prism.Ui.Adapters;

using Connection = Editor.NodeEditor.Connection;

namespace Editor.Prism.Ui;

/// <summary>
/// A wire.
/// <para>
/// The built-in connection paints a single four-pixel stroke and switches to a hardcoded pink when
/// selected or hovered. Prism paints the source port's <em>resolved</em> type colour over a
/// canvas-coloured halo, so a dense graph stays readable where wires cross, and marks the two things
/// the built-in editor hides: a lossy or padded conversion gets a diamond at its midpoint with the
/// exact conversion in its tooltip, and an illegal one is drawn dashed in the error colour ending in an
/// X rather than silently truncating at compile time.
/// </para>
/// <para>
/// Two gestures are added on top of the framework's own: a double click inserts a reroute at the point
/// clicked, and Ctrl+drag pulls a <em>second</em> wire out of the same output instead of moving this
/// one. Shift+click still reroutes, and a plain drag still relocates the wire's input end.
/// </para>
/// </summary>
public class PrismConnection : Connection
{
	/// <summary>How long two presses may be apart and still count as a double-click.</summary>
	const float DoubleClickSeconds = 0.4f;

	/// <summary>How far the pointer may travel between them.</summary>
	const float DoubleClickSlop = 6f;

	RealTimeSince _sinceLastPress = 1000f;
	Vector2 _lastPress;
	bool _duplicateArmed;
	bool _duplicating;

	/// <summary>Build a wire between two sockets.</summary>
	public PrismConnection( PlugOut output, PlugIn input ) : base( output, input )
	{
		ZIndex = -10;
		HoverEvents = true;
		Selectable = true;
		Cursor = CursorShape.Finger;
	}

	/// <summary>The Prism output plug this wire leaves, when there is one.</summary>
	public PrismPlugOut Source => Output.IsValid() ? Output.Inner as PrismPlugOut : null;

	/// <summary>The Prism input plug this wire enters, when there is one.</summary>
	public PrismPlugIn Target => Input.IsValid() ? Input.Inner as PrismPlugIn : null;

	/// <summary>The type flowing down the wire.</summary>
	public ShaderType SourceType => Source?.EffectiveType ?? ShaderType.Void;

	/// <summary>The type the wire has to arrive as.</summary>
	public ShaderType TargetType => Target?.EffectiveType ?? ShaderType.Void;

	/// <summary>What the compiler will have to do to get one type into the other.</summary>
	public ConversionKind Conversion
	{
		get
		{
			var from = SourceType;
			var to = TargetType;

			if ( from.IsVoid || to.IsVoid ) return ConversionKind.Identity;

			return TypeRules.Classify( from, to );
		}
	}

	/// <summary>True when the conversion this wire implies is not legal at all.</summary>
	public bool IsBroken => Conversion == ConversionKind.Illegal;

	/// <summary>True when the conversion costs information or invents it.</summary>
	public bool IsLossy => TypeRules.IsLossy( Conversion );

	/// <summary>The wire's colour before selection and reachability are taken into account.</summary>
	public Color BaseColor
	{
		get
		{
			if ( IsBroken ) return PrismTheme.Error;

			var plug = Source ?? (PrismPlug)Target;

			return plug?.TypeColor ?? PrismTheme.TypeGeneric;
		}
	}

	/// <inheritdoc/>
	protected override void OnPaint()
	{
		if ( !Output.IsValid() && !Input.IsValid() ) return;

		// The base hides itself while the wire is being re-dragged; its flag is private, but it also
		// swaps the cursor, which is observable.
		if ( Cursor == CursorShape.DragLink ) return;

		Paint.Antialiasing = true;

		var selected = Paint.HasSelected;
		var hovered = Paint.HasMouseOver;

		var color = BaseColor;

		if ( ColorTint.a > 0f ) color = Color.Lerp( color, ColorTint.WithAlpha( 1f ), ColorTint.a );

		var reachable = ( Output.IsValid() && Output.Node.Node.IsReachable )
			|| ( Input.IsValid() && Input.Node.Node.IsReachable );

		if ( !reachable ) color = color.Desaturate( 0.55f ).Darken( 0.3f );

		if ( selected ) color = PrismTheme.Accent;

		var width = ( hovered ? PrismTheme.WireWidthHover : PrismTheme.WireWidth ) * MathF.Max( 0.1f, WidthScale );

		if ( selected )
		{
			// The framework's selected wire is a hardcoded pink; ours is the theme accent with a glow.
			Paint.ClearBrush();
			Paint.SetPen( PrismTheme.Accent.WithAlpha( 0.2f ), width + 6f );
			PaintLine();
		}

		Paint.ClearBrush();
		Paint.SetPen( PrismTheme.Canvas.WithAlpha( 0.85f ), width + PrismTheme.WireHaloWidth - PrismTheme.WireWidth );
		PaintLine();

		Paint.SetPen( color, width, IsBroken ? PenStyle.Dash : PenStyle.Solid );
		PaintLine();

		PaintEndpoints( color );
		PaintMarker( color );
	}

	void PaintEndpoints( Color color )
	{
		if ( Output.IsValid() )
		{
			var point = FromScene( OutputPosition );

			PrismPaint.Dot( point, PrismTheme.WireEndpointRadius * 0.5f + 1f, PrismTheme.Canvas );
			PrismPaint.Dot( point, PrismTheme.WireEndpointRadius * 0.5f, color );
		}

		if ( !Input.IsValid() ) return;

		var target = FromScene( InputPosition );

		if ( IsBroken )
		{
			PrismPaint.Cross( target, 4f, PrismTheme.Error );
			return;
		}

		PrismPaint.Dot( target, PrismTheme.WireEndpointRadius * 0.5f + 1f, PrismTheme.Canvas );
		PrismPaint.Dot( target, PrismTheme.WireEndpointRadius * 0.5f, color );
	}

	void PaintMarker( Color color )
	{
		if ( !IsLossy ) return;
		if ( !Output.IsValid() || !Input.IsValid() ) return;

		var mid = FromScene( ( OutputPosition + InputPosition ) * 0.5f );

		PrismPaint.Diamond( mid, 4.5f, PrismTheme.Warning, PrismTheme.Canvas );
	}

	/// <summary>The document edge this wire draws, when it is a real connection rather than a preview.</summary>
	public bool TryGetEdge( out Edge edge )
	{
		edge = null;

		var target = Target;
		var node = target?.Owner?.PrismNode;
		var document = target?.Owner?.Adapter?.Document;

		if ( node is null || document is null ) return false;

		return document.TryGetIncomingEdge( node.Id, target.PortId, out edge );
	}

	/// <summary>
	/// Insert a reroute in the middle of this wire.
	/// <para>
	/// Reimplemented rather than delegated because <c>GraphView.RerouteConnection</c> — what the built-in
	/// SHIFT+click gesture calls — is <c>internal</c> and unreachable from an addon assembly. Going
	/// through <c>GraphMutations.InsertReroute</c> instead also means the split is one undo step and the
	/// edge ids on both halves are minted the same way every other edge is.
	/// </para>
	/// </summary>
	public bool InsertReroute( Vector2 scenePosition )
	{
		if ( !TryGetEdge( out var edge ) ) return false;

		var mutations = Target?.Owner?.Adapter?.Mutations;

		if ( mutations is null ) return false;

		return PrismLog.Guard( "Insert a reroute into a wire",
			() => mutations.InsertReroute( edge.Id, scenePosition ) is not null, false );
	}

	/// <inheritdoc/>
	protected override void OnMousePressed( GraphicsMouseEvent e )
	{
		// Ctrl+press only *arms* the duplicate; the gesture is taken over on the first move. Nothing is
		// accepted here on purpose, so Qt's own selectable-item handling still runs and Ctrl+click keeps
		// working as additive selection when the pointer never moves.
		_duplicateArmed = e.LeftMouseButton && e.HasCtrl && !e.HasShift && !e.HasAlt && Output.IsValid();

		if ( _duplicateArmed ) return;

		if ( e.LeftMouseButton && !e.HasShift && !e.HasCtrl && !e.HasAlt )
		{
			var quick = _sinceLastPress < DoubleClickSeconds
				&& ( e.ScenePosition - _lastPress ).Length < DoubleClickSlop;

			_sinceLastPress = 0f;
			_lastPress = e.ScenePosition;

			// There is no double-click event on a graphics item, so the second press inside the window
			// is what we act on. Not calling base is deliberate: base re-drags the wire's output end,
			// and the user asked for a reroute, not a re-drag.
			if ( quick && InsertReroute( e.ScenePosition ) )
			{
				_sinceLastPress = 1000f;
				e.Accepted = true;

				return;
			}
		}

		base.OnMousePressed( e );
	}

	/// <summary>
	/// Pull a second wire out of this one's source instead of moving it.
	/// <para>
	/// The framework's own drag on a wire re-drags its <em>output</em> end, which relocates the
	/// connection. Ctrl instead forwards the gesture straight to the source plug through <c>Plug</c>'s
	/// public event relays — which exist precisely so one item can drive another's gesture — so the
	/// framework sees an ordinary drag begun at that output: it opens a preview wire, tracks a drop
	/// target, and on release creates a new connection under a <c>"Create Connection"</c> undo step. The
	/// original wire is never told anything happened, which is exactly what "duplicate" means.
	/// </para>
	/// </summary>
	protected override void OnMouseMove( GraphicsMouseEvent e )
	{
		if ( _duplicateArmed )
		{
			if ( !Output.IsValid() )
			{
				_duplicateArmed = false;
				_duplicating = false;

				return;
			}

			_duplicating = true;

			Output.MouseMove( e );

			e.Accepted = true;

			return;
		}

		base.OnMouseMove( e );
	}

	/// <inheritdoc/>
	protected override void OnMouseReleased( GraphicsMouseEvent e )
	{
		var duplicating = _duplicating;

		_duplicateArmed = false;
		_duplicating = false;

		if ( duplicating )
		{
			// Drops the preview wire onto whatever is under the cursor, or opens the create-node menu
			// filtered to this output when there is nothing there — the same two outcomes a drag from
			// the socket itself would have had.
			if ( Output.IsValid() ) Output.MouseReleased( e );

			e.Accepted = true;

			return;
		}

		base.OnMouseReleased( e );
	}

	/// <inheritdoc/>
	protected override void OnHoverEnter( GraphicsHoverEvent e )
	{
		base.OnHoverEnter( e );

		var source = Source;
		var target = Target;

		if ( source is null || target is null ) return;

		var from = SourceType;
		var to = TargetType;
		var conversion = Conversion;

		var text = $"<span style=\"white-space: nowrap;\">" +
			$"<b>{PrismShaderTypes.Name( from, source.IsColor )}</b> → " +
			$"<b>{PrismShaderTypes.Name( to, target.IsColor )}</b><br/>" +
			$"{source.Owner?.DisplayInfo.Name} › {source.Label} → " +
			$"{target.Owner?.DisplayInfo.Name} › {target.Label}";

		if ( conversion != ConversionKind.Identity && !from.IsVoid && !to.IsVoid )
		{
			var color = conversion == ConversionKind.Illegal ? PrismTheme.Error
				: TypeRules.IsLossy( conversion ) ? PrismTheme.Warning
				: PrismTheme.TextMuted;

			text += $"<br/><span style=\"color: {color.Hex};\">{TypeRules.Describe( from, to, conversion )}</span>";
		}

		ToolTip = text + "</span>";
	}
}