Editor/Prism/Ui/Adapters/PrismPlugItems.cs

Editor UI adapters that replace framework node graph plugs with Prism-specific plug items implementing a screen-space drag threshold. PrismPlugItems.Upgrade swaps framework PlugIn/PlugOut instances with PrismPlugInItem/PrismPlugOutItem when safe. PrismPlugInItem and PrismPlugOutItem delay forwarding press events until the pointer has moved at least DragThreshold pixels, so clicks do not spawn ghost wires.

Native Interop
namespace Editor.Prism.Ui.Adapters;

/// <summary>
/// The socket graphics items Prism uses in place of the framework's own.
/// <para>
/// The node-graph library starts a wire drag on the <em>first pixel</em> of pointer movement over a
/// plug — its own source carries a <c>// TODO - minimum distance move</c> where the threshold should
/// be. The consequences are visible every session: a plain click on a handle spawns a ghost wire, and
/// releasing it a pixel later drops it on nothing, which pops the create-node menu; and a click on a
/// <em>connected</em> input tears the wire off before the user has moved at all.
/// </para>
/// <para>
/// <c>NodeUI.UpdatePlugs</c> is private and hardcodes <c>new PlugIn(...)</c> / <c>new PlugOut(...)</c>,
/// so a subclass cannot be injected at creation time — but <c>NodeUI.Inputs</c> and
/// <c>NodeUI.Outputs</c> are public lists and both plug constructors are public, so a freshly created
/// plug can be swapped for one of these the moment it appears. <see cref="Upgrade"/> does that, from
/// <c>PrismNodeUi.Layout()</c>, which the framework calls at the end of every <c>UpdatePlugs</c> pass —
/// before anything has had a chance to connect to the plug it replaces.
/// </para>
/// </summary>
public static class PrismPlugItems
{
	/// <summary>
	/// How far the pointer must travel, in screen pixels, before a press on a handle becomes a wire drag.
	/// Measured on screen rather than in the scene so the gesture feels identical at every zoom level.
	/// </summary>
	public const float DragThreshold = 4f;

	/// <summary>
	/// True when a press is one the framework's plug would have swallowed and turned into a drag. Any
	/// other press — right button, Ctrl, Shift — is left entirely alone so the framework's own gestures
	/// (marquee from a plug, additive selection) keep working exactly as they did.
	/// </summary>
	internal static bool IsDragPress( in GraphicsMouseEvent e ) =>
		e.LeftMouseButton && !e.HasCtrl && !e.HasShift;

	/// <summary>True when the pointer has moved far enough from the press for this to be a drag.</summary>
	internal static bool Crossed( Vector2 pressScreen, Vector2 nowScreen ) =>
		( nowScreen - pressScreen ).Length >= DragThreshold;

	/// <summary>
	/// Replace every framework-created plug on a card with the Prism equivalent.
	/// <para>
	/// Connected plugs are left alone: a <c>Connection</c> holds its endpoints by reference, so swapping
	/// one out from under a live wire would orphan it. In practice this never bites — a plug is upgraded
	/// on the same call that created it, long before a wire can reach it — and the guard means the worst
	/// case is a socket that keeps the stock behaviour rather than a canvas that loses a connection.
	/// </para>
	/// </summary>
	/// <returns>How many plugs were replaced.</returns>
	public static int Upgrade( NodeUI card )
	{
		if ( card is null || !card.IsValid ) return 0;

		var upgraded = 0;

		for ( int i = 0; i < card.Inputs.Count; i++ )
		{
			var plug = card.Inputs[i];

			if ( plug is PrismPlugInItem || !plug.IsValid() || plug.IsConnected ) continue;

			card.Inputs[i] = new PrismPlugInItem( card, plug.Inner );

			plug.Destroy();
			upgraded++;
		}

		for ( int i = 0; i < card.Outputs.Count; i++ )
		{
			var plug = card.Outputs[i];

			if ( plug is PrismPlugOutItem || !plug.IsValid() || plug.IsConnected ) continue;

			card.Outputs[i] = new PrismPlugOutItem( card, plug.Inner );

			plug.Destroy();
			upgraded++;
		}

		return upgraded;
	}
}

/// <summary>
/// An input socket with a drag threshold.
/// <para>
/// The press is held back rather than forwarded, so nothing at all happens until the pointer has
/// travelled <see cref="PrismPlugItems.DragThreshold"/>. Only then is the base's press replayed — which
/// is the call that detaches an existing wire and starts re-dragging it — followed immediately by the
/// move that provoked it. A press that never crosses the threshold is simply a click on a handle, and
/// leaves both the canvas and the document exactly as they were.
/// </para>
/// </summary>
public sealed class PrismPlugInItem : PlugIn
{
	Vector2 _pressScreen;
	bool _armed;
	bool _dragging;

	/// <summary>Build an input socket for a card.</summary>
	public PrismPlugInItem( NodeUI node, IPlug plug ) : base( node, plug ) { }

	/// <summary>True while a press is being held below the drag threshold.</summary>
	public bool IsArmed => _armed && !_dragging;

	/// <inheritdoc/>
	protected override void OnMousePressed( GraphicsMouseEvent e )
	{
		if ( PrismPlugItems.IsDragPress( e ) )
		{
			_pressScreen = e.ScreenPosition;
			_armed = true;
			_dragging = false;

			// Accepted for exactly the same reason the base accepts it: to keep the grab, so the moves
			// and the release arrive here and not on the card underneath.
			e.Accepted = true;

			return;
		}

		base.OnMousePressed( e );
	}

	/// <inheritdoc/>
	protected override void OnMouseMove( GraphicsMouseEvent e )
	{
		if ( _armed && !_dragging )
		{
			if ( !PrismPlugItems.Crossed( _pressScreen, e.ScreenPosition ) )
			{
				e.Accepted = true;
				return;
			}

			_dragging = true;

			// Replay the press the gate withheld. This is what picks an existing wire up.
			base.OnMousePressed( e );
		}

		base.OnMouseMove( e );
	}

	/// <inheritdoc/>
	protected override void OnMouseReleased( GraphicsMouseEvent e )
	{
		var wasClick = _armed && !_dragging;

		_armed = false;
		_dragging = false;

		// Still called on a click: with no drag in flight the base only resets the cursor and repaints,
		// and skipping it would leave a stale DragLink cursor behind on the very next real drag.
		base.OnMouseReleased( e );

		if ( wasClick ) e.Accepted = true;
	}
}

/// <summary>
/// An output socket with a drag threshold. Same gate as <see cref="PrismPlugInItem"/>; an output has no
/// wire to detach, so the only thing the threshold buys here is that a click stops producing a ghost
/// wire and a create-node menu nobody asked for.
/// </summary>
public sealed class PrismPlugOutItem : PlugOut
{
	Vector2 _pressScreen;
	bool _armed;
	bool _dragging;

	/// <summary>Build an output socket for a card.</summary>
	public PrismPlugOutItem( NodeUI node, IPlug plug ) : base( node, plug ) { }

	/// <summary>True while a press is being held below the drag threshold.</summary>
	public bool IsArmed => _armed && !_dragging;

	/// <inheritdoc/>
	protected override void OnMousePressed( GraphicsMouseEvent e )
	{
		if ( PrismPlugItems.IsDragPress( e ) )
		{
			_pressScreen = e.ScreenPosition;
			_armed = true;
			_dragging = false;

			e.Accepted = true;

			return;
		}

		base.OnMousePressed( e );
	}

	/// <inheritdoc/>
	protected override void OnMouseMove( GraphicsMouseEvent e )
	{
		if ( _armed && !_dragging )
		{
			if ( !PrismPlugItems.Crossed( _pressScreen, e.ScreenPosition ) )
			{
				e.Accepted = true;
				return;
			}

			_dragging = true;

			base.OnMousePressed( e );
		}

		base.OnMouseMove( e );
	}

	/// <inheritdoc/>
	protected override void OnMouseReleased( GraphicsMouseEvent e )
	{
		var wasClick = _armed && !_dragging;

		_armed = false;
		_dragging = false;

		base.OnMouseReleased( e );

		if ( wasClick ) e.Accepted = true;
	}
}