• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

orion-ecs / keen-eye / 20767041456

07 Jan 2026 12:58AM UTC coverage: 87.804% (+17.6%) from 70.212%
20767041456

push

github

tyevco
fix: Resolve all SonarAnalyzer errors for clean build

Fix all analyzer violations to achieve 0 errors, 0 warnings with
SonarAnalyzer.CSharp v10.17.0 and TreatWarningsAsErrors enabled.

Key fixes:
- S2325: Make methods static where appropriate
- S1066: Merge nested if statements
- S1481: Replace unused variables with discards
- S127: Refactor for loops to while loops in CLI arg parsing
- S3218: Rename properties that shadow System types
- S3878: Suppress where conflicting with S3220 (params array)
- S3904: Suppress for SDK projects (no assembly output)
- S3881: Implement proper IDisposable pattern
- S2292: Convert to auto-implemented properties
- IDE0059: Remove unnecessary variable assignments

Also updates test files to use static method calls after
refactoring instance methods to static.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

8869 of 11773 branches covered (75.33%)

Branch coverage included in aggregate %.

639 of 867 new or added lines in 131 files covered. (73.7%)

15 existing lines in 11 files now uncovered.

155014 of 174874 relevant lines covered (88.64%)

1.01 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.66
/src/KeenEyes.Graph/Systems/GraphInputSystem.cs
1
using System.Numerics;
2
using KeenEyes.Common;
3
using KeenEyes.Editor.Abstractions;
4
using KeenEyes.Graph.Abstractions;
5
using KeenEyes.Graphics.Abstractions;
6
using KeenEyes.Input.Abstractions;
7

8
namespace KeenEyes.Graph;
9

10
/// <summary>
11
/// System that processes input events for graph editing.
12
/// </summary>
13
/// <remarks>
14
/// <para>
15
/// Handles pan, zoom, node selection, node dragging, and port interaction for all graph canvases.
16
/// </para>
17
/// <para>
18
/// Controls:
19
/// <list type="bullet">
20
/// <item><description>Mouse wheel: Zoom (centered on cursor)</description></item>
21
/// <item><description>Middle mouse drag: Pan</description></item>
22
/// <item><description>Left click on port: Start connection drag</description></item>
23
/// <item><description>Left click on node: Select node (Ctrl to add to selection)</description></item>
24
/// <item><description>Left drag on empty: Box selection</description></item>
25
/// <item><description>Left drag on node: Move selected nodes</description></item>
26
/// <item><description>Delete key: Delete selected nodes/connections</description></item>
27
/// <item><description>ESC/Right click: Cancel connection drag</description></item>
28
/// </list>
29
/// </para>
30
/// </remarks>
31
public sealed class GraphInputSystem : SystemBase
32
{
33
    private IInputContext? inputContext;
34
    private GraphContext? graphContext;
35
    private PortRegistry? portRegistry;
36
    private NodeTypeRegistry? nodeTypeRegistry;
37
    private PortPositionCache? portCache;
38
    private IUndoRedoManager? undoManager;
39

40
    // Interaction state
41
    private Vector2 dragStartScreen;
42
    private Vector2 dragStartPan;
43
    private Vector2 lastMousePos;
44
    private Vector2 selectionBoxStart;
45
    private bool isDraggingNodes;
46
    private bool isSelecting;
47
    private bool isPanning;
48

49
    // Undo support for node dragging
50
    private readonly Dictionary<Entity, Vector2> dragStartPositions = [];
1✔
51

52
    // Key debouncing
53
    private readonly HashSet<Key> keysDownLastFrame = [];
1✔
54

55
    // Connection dragging state
56
    private Entity connectionSourceNode;
57
    private int connectionSourcePort;
58
    private bool connectionFromOutput;
59
    private PortTypeId connectionSourceType;
60
    private bool isConnecting;
61

62
    // Canvas bounds cache (updated by layout system)
63
    private Rectangle canvasBounds = new(0, 0, 1280, 720);
1✔
64

65
    private const float ZoomSpeed = 0.1f;
66
    private const float DragThreshold = 5f;
67
    private const float CollapseButtonSize = 12f;
68
    private const float CollapseButtonPadding = 8f;
69

70
    /// <summary>
71
    /// Sets the canvas screen bounds for hit testing.
72
    /// </summary>
73
    /// <param name="bounds">The screen rectangle of the canvas area.</param>
74
    public void SetCanvasBounds(Rectangle bounds)
75
    {
76
        canvasBounds = bounds;
×
77
    }
×
78

79
    /// <inheritdoc />
80
    public override void Update(float deltaTime)
81
    {
82
        // Lazy initialization
83
        if (inputContext is null && !World.TryGetExtension(out inputContext))
×
84
        {
85
            return;
×
86
        }
87

88
        if (graphContext is null && !World.TryGetExtension(out graphContext))
×
89
        {
90
            return;
×
91
        }
92

93
        if (portRegistry is null && !World.TryGetExtension(out portRegistry))
×
94
        {
95
            return;
×
96
        }
97

98
        if (nodeTypeRegistry is null)
×
99
        {
100
            World.TryGetExtension(out nodeTypeRegistry);
×
101
        }
102

103
        if (portCache is null && !World.TryGetExtension(out portCache))
×
104
        {
105
            return;
×
106
        }
107

108
        if (undoManager is null)
×
109
        {
110
            World.TryGetExtension(out undoManager);
×
111
        }
112

113
        var mouse = inputContext!.Mouse;
×
114
        var keyboard = inputContext.Keyboard;
×
115
        var mousePos = mouse.Position;
×
116

117
        // Process each canvas
118
        foreach (var canvas in World.Query<GraphCanvas, GraphCanvasTag>())
×
119
        {
120
            ProcessCanvas(canvas, mouse, keyboard, mousePos);
×
121
        }
122

123
        lastMousePos = mousePos;
×
124

125
        // Update key state for next frame
126
        UpdateKeyState(keyboard);
×
127
    }
×
128

129
    private void ProcessCanvas(Entity canvas, IMouse mouse, IKeyboard keyboard, Vector2 mousePos)
130
    {
131
        ref var canvasData = ref World.Get<GraphCanvas>(canvas);
×
132
        var origin = new Vector2(canvasBounds.X, canvasBounds.Y);
×
133

134
        // Skip input if context menu is open
135
        if (canvasData.Mode == GraphInteractionMode.ContextMenu)
×
136
        {
137
            return;
×
138
        }
139

140
        // Handle keyboard shortcuts (before other input)
141
        HandleKeyboardShortcuts(canvas, ref canvasData, keyboard, mousePos, origin);
×
142

143
        // Always update hovered port
144
        UpdateHoveredPort(canvas, in canvasData, mousePos, origin);
×
145

146
        // Handle zoom
147
        var scrollDelta = mouse.GetState().ScrollDelta.Y;
×
148
        if (!scrollDelta.IsApproximatelyZero())
×
149
        {
150
            ProcessZoom(ref canvasData, scrollDelta, mousePos, origin);
×
151
        }
152

153
        // Handle middle mouse panning
154
        if (mouse.IsButtonDown(MouseButton.Middle))
×
155
        {
156
            if (!isPanning)
×
157
            {
158
                StartPan(ref canvasData, mousePos);
×
159
            }
160
            else
161
            {
162
                UpdatePan(ref canvasData, mousePos, origin);
×
163
            }
164
        }
165
        else if (isPanning)
×
166
        {
167
            EndPan(ref canvasData);
×
168
        }
169

170
        // Handle connection in progress
171
        if (isConnecting && canvasData.Mode == GraphInteractionMode.ConnectingPort)
×
172
        {
173
            UpdateConnection(canvas, ref canvasData, mousePos, origin, mouse, keyboard);
×
174
            return; // Connection mode takes exclusive control
×
175
        }
176

177
        // Handle left mouse for selection, dragging, and port interaction
178
        if (mouse.IsButtonDown(MouseButton.Left))
×
179
        {
180
            if (!isDraggingNodes && !isSelecting && canvasData.Mode == GraphInteractionMode.None)
×
181
            {
182
                StartLeftMouseAction(canvas, ref canvasData, mousePos, origin, keyboard);
×
183
            }
184
            else if (isDraggingNodes)
×
185
            {
186
                UpdateNodeDrag(canvas, ref canvasData, mousePos, origin);
×
187
            }
188
            else if (isSelecting)
×
189
            {
190
                UpdateSelection(canvas, ref canvasData, mousePos, origin);
×
191
            }
192
        }
193
        else
194
        {
195
            if (isDraggingNodes)
×
196
            {
197
                EndNodeDrag(ref canvasData);
×
198
            }
199

200
            if (isSelecting)
×
201
            {
202
                EndSelection(canvas, ref canvasData, origin);
×
203
            }
204
        }
205

206
        // Handle delete key
207
        if (keyboard.IsKeyDown(Key.Delete) || keyboard.IsKeyDown(Key.Backspace))
×
208
        {
209
            DeleteSelected();
×
210
        }
211
    }
×
212

213
    private static void ProcessZoom(ref GraphCanvas canvasData, float scrollDelta, Vector2 mousePos, Vector2 origin)
214
    {
215
        var zoomFactor = 1f + (scrollDelta > 0 ? ZoomSpeed : -ZoomSpeed);
×
216
        var newZoom = Math.Clamp(canvasData.Zoom * zoomFactor, canvasData.MinZoom, canvasData.MaxZoom);
×
217

218
        if (Math.Abs(newZoom - canvasData.Zoom) > 0.001f)
×
219
        {
220
            canvasData.Pan = GraphTransform.ZoomToPoint(canvasData.Pan, canvasData.Zoom, newZoom, mousePos, origin);
×
221
            canvasData.Zoom = newZoom;
×
222
        }
223
    }
×
224

225
    private void StartPan(ref GraphCanvas canvasData, Vector2 mousePos)
226
    {
227
        isPanning = true;
×
228
        dragStartScreen = mousePos;
×
229
        dragStartPan = canvasData.Pan;
×
230
        canvasData.Mode = GraphInteractionMode.Panning;
×
231
    }
×
232

233
    private void UpdatePan(ref GraphCanvas canvasData, Vector2 mousePos, Vector2 origin)
234
    {
235
        var screenDelta = mousePos - dragStartScreen;
×
236
        var canvasDelta = screenDelta / canvasData.Zoom;
×
237
        canvasData.Pan = dragStartPan - canvasDelta;
×
238
    }
×
239

240
    private void EndPan(ref GraphCanvas canvasData)
241
    {
242
        isPanning = false;
×
243
        canvasData.Mode = GraphInteractionMode.None;
×
244
    }
×
245

246
    private void StartLeftMouseAction(Entity canvas, ref GraphCanvas canvasData, Vector2 mousePos, Vector2 origin, IKeyboard keyboard)
247
    {
248
        dragStartScreen = mousePos;
×
249

250
        // First check for port hit (ports have priority over node body)
251
        if (portCache!.HitTestPort(mousePos, canvasData.Pan, canvasData.Zoom, origin,
×
252
            out var hitPortNode, out var hitPortDir, out var hitPortIndex))
×
253
        {
254
            // Check if this is an input port with an existing connection (disconnect + reconnect)
255
            if (hitPortDir == PortDirection.Input)
×
256
            {
257
                var existingConnection = FindConnectionToInput(hitPortNode, hitPortIndex);
×
258
                if (existingConnection.IsValid)
×
259
                {
260
                    // Get the source info before deleting
261
                    ref readonly var conn = ref World.Get<GraphConnection>(existingConnection);
×
262
                    var sourceNode = conn.SourceNode;
×
263
                    var sourcePort = conn.SourcePortIndex;
×
264

265
                    // Delete the existing connection
266
                    graphContext!.DeleteConnection(existingConnection);
×
267

268
                    // Start connection from the original source output
269
                    StartConnection(canvas, ref canvasData, sourceNode, PortDirection.Output, sourcePort, mousePos, origin);
×
270
                    return;
×
271
                }
272
            }
273

274
            // Start a new connection from this port
275
            StartConnection(canvas, ref canvasData, hitPortNode, hitPortDir, hitPortIndex, mousePos, origin);
×
276
            return;
×
277
        }
278

279
        // Hit test nodes
280
        var hitNode = HitTestNodes(canvas, mousePos, canvasData.Pan, canvasData.Zoom, origin);
×
281

282
        if (hitNode.IsValid)
×
283
        {
284
            ref readonly var hitNodeData = ref World.Get<GraphNode>(hitNode);
×
285

286
            // Check if clicked on collapse button
287
            if (HitTestCollapseButton(hitNode, in hitNodeData, mousePos, canvasData.Zoom, origin))
×
288
            {
289
                ToggleNodeCollapse(hitNode);
×
290
                return; // Don't start selection/drag
×
291
            }
292

293
            // Clicked on a node
294
            var addToSelection = (keyboard.Modifiers & KeyModifiers.Control) != 0;
×
295

296
            if (!World.Has<GraphNodeSelectedTag>(hitNode))
×
297
            {
298
                graphContext!.SelectNode(hitNode, addToSelection);
×
299
            }
300
            else if (addToSelection)
×
301
            {
302
                // Toggle selection if Ctrl-clicking already selected node
303
                graphContext!.DeselectNode(hitNode);
×
304
            }
305

306
            // Prepare for potential drag
307
            isDraggingNodes = false; // Wait for movement threshold
×
308
        }
309
        else
310
        {
311
            // Clicked on empty space - start box selection
312
            selectionBoxStart = mousePos;
×
313
            isSelecting = false; // Wait for movement threshold
×
314
            canvasData.Mode = GraphInteractionMode.Selecting;
×
315

316
            if ((keyboard.Modifiers & KeyModifiers.Control) == 0)
×
317
            {
318
                graphContext!.ClearSelection();
×
319
            }
320
        }
321
    }
×
322

323
    private void UpdateNodeDrag(Entity canvas, ref GraphCanvas canvasData, Vector2 mousePos, Vector2 origin)
324
    {
325
        if (!isDraggingNodes)
×
326
        {
327
            // Check drag threshold
328
            var distance = Vector2.Distance(mousePos, dragStartScreen);
×
329
            if (distance >= DragThreshold)
×
330
            {
331
                isDraggingNodes = true;
×
332
                canvasData.Mode = GraphInteractionMode.DraggingNode;
×
333

334
                // Store start positions for undo
335
                dragStartPositions.Clear();
×
336
                foreach (var node in graphContext!.GetSelectedNodes())
×
337
                {
338
                    if (World.IsAlive(node))
×
339
                    {
340
                        ref readonly var nodeData = ref World.Get<GraphNode>(node);
×
341
                        dragStartPositions[node] = nodeData.Position;
×
342

343
                        if (!World.Has<GraphNodeDraggingTag>(node))
×
344
                        {
345
                            World.Add(node, new GraphNodeDraggingTag());
×
346
                        }
347
                    }
348
                }
349
            }
350
        }
351

352
        if (isDraggingNodes)
×
353
        {
354
            // Move all selected nodes
355
            var screenDelta = mousePos - lastMousePos;
×
356
            var canvasDelta = screenDelta / canvasData.Zoom;
×
357

358
            foreach (var node in graphContext!.GetSelectedNodes())
×
359
            {
360
                ref var nodeData = ref World.Get<GraphNode>(node);
×
361
                nodeData.Position += canvasDelta;
×
362

363
                if (canvasData.SnapToGrid)
×
364
                {
365
                    nodeData.Position = GraphTransform.SnapToGrid(nodeData.Position, canvasData.GridSize);
×
366
                }
367
            }
368
        }
369
    }
×
370

371
    private void EndNodeDrag(ref GraphCanvas canvasData)
372
    {
373
        // Create undo command if nodes were actually moved
374
        if (dragStartPositions.Count > 0)
×
375
        {
376
            var nodePositions = new Dictionary<Entity, (Vector2 OldPosition, Vector2 NewPosition)>();
×
377

378
            foreach (var kvp in dragStartPositions)
×
379
            {
380
                var node = kvp.Key;
×
381
                var startPos = kvp.Value;
×
382

383
                if (World.IsAlive(node) && World.Has<GraphNode>(node))
×
384
                {
385
                    ref readonly var nodeData = ref World.Get<GraphNode>(node);
×
386
                    if (nodeData.Position != startPos)
×
387
                    {
388
                        nodePositions[node] = (startPos, nodeData.Position);
×
389
                    }
390
                }
391
            }
392

393
            if (nodePositions.Count > 0)
×
394
            {
395
                graphContext!.MoveNodesUndoable(nodePositions);
×
396
            }
397

398
            dragStartPositions.Clear();
×
399
        }
400

401
        isDraggingNodes = false;
×
402
        canvasData.Mode = GraphInteractionMode.None;
×
403

404
        // Remove dragging tags
405
        foreach (var entity in World.Query<GraphNodeDraggingTag>())
×
406
        {
407
            World.Remove<GraphNodeDraggingTag>(entity);
×
408
        }
409
    }
×
410

411
    private void UpdateSelection(Entity canvas, ref GraphCanvas canvasData, Vector2 mousePos, Vector2 origin)
412
    {
413
        if (!isSelecting)
×
414
        {
415
            var distance = Vector2.Distance(mousePos, selectionBoxStart);
×
416
            if (distance >= DragThreshold)
×
417
            {
418
                isSelecting = true;
×
419
            }
420
        }
421
    }
×
422

423
    private void EndSelection(Entity canvas, ref GraphCanvas canvasData, Vector2 origin)
424
    {
425
        if (isSelecting)
×
426
        {
427
            // Select all nodes within the selection box
428
            var selectionBox = GraphTransform.CreateSelectionBox(selectionBoxStart, lastMousePos);
×
429

430
            foreach (var node in World.Query<GraphNode>())
×
431
            {
432
                ref readonly var nodeData = ref World.Get<GraphNode>(node);
×
433
                if (nodeData.Canvas != canvas)
×
434
                {
435
                    continue;
436
                }
437

438
                var nodeRect = new Rectangle(nodeData.Position.X, nodeData.Position.Y, nodeData.Width, nodeData.Height);
×
439
                var screenRect = GraphTransform.CanvasToScreen(nodeRect, canvasData.Pan, canvasData.Zoom, origin);
×
440

NEW
441
                if (selectionBox.Intersects(screenRect) && !World.Has<GraphNodeSelectedTag>(node))
×
442
                {
NEW
443
                    World.Add(node, new GraphNodeSelectedTag());
×
444
                }
445
            }
446
        }
447

448
        isSelecting = false;
×
449
        canvasData.Mode = GraphInteractionMode.None;
×
450
    }
×
451

452
    private Entity HitTestNodes(Entity canvas, Vector2 screenPos, Vector2 pan, float zoom, Vector2 origin)
453
    {
454
        // Test in reverse order (top-most first, assuming last added is on top)
455
        Entity hitNode = Entity.Null;
×
456

457
        foreach (var node in World.Query<GraphNode>())
×
458
        {
459
            ref readonly var nodeData = ref World.Get<GraphNode>(node);
×
460
            if (nodeData.Canvas != canvas)
×
461
            {
462
                continue;
463
            }
464

465
            var nodeRect = new Rectangle(nodeData.Position.X, nodeData.Position.Y, nodeData.Width, nodeData.Height);
×
466
            if (GraphTransform.HitTest(screenPos, nodeRect, pan, zoom, origin))
×
467
            {
468
                hitNode = node;
×
469
                // Don't break - continue to find the "top-most" (last) node
470
            }
471
        }
472

473
        return hitNode;
×
474
    }
475

476
    private bool HitTestCollapseButton(Entity node, ref readonly GraphNode nodeData, Vector2 screenPos, float zoom, Vector2 origin)
477
    {
478
        // Check if node type is collapsible
479
        var definition = nodeTypeRegistry?.GetDefinition(nodeData.NodeTypeId);
×
480
        if (definition is null || !definition.IsCollapsible)
×
481
        {
482
            return false;
×
483
        }
484

485
        // Calculate collapse button bounds in canvas space
486
        var buttonSize = CollapseButtonSize;
×
487
        var padding = CollapseButtonPadding;
×
488
        var headerHeight = GraphLayoutSystem.HeaderHeight;
×
489

490
        // Button position in canvas coordinates (right side of header)
491
        var buttonX = nodeData.Position.X + nodeData.Width - padding - buttonSize;
×
492
        var buttonY = nodeData.Position.Y + ((headerHeight - buttonSize) / 2f);
×
493

494
        var buttonRect = new Rectangle(buttonX, buttonY, buttonSize, buttonSize);
×
495

496
        // Hit test using the canvas pan
497
        ref readonly var canvasComponent = ref World.Get<GraphCanvas>(nodeData.Canvas);
×
498
        return GraphTransform.HitTest(screenPos, buttonRect, canvasComponent.Pan, zoom, origin);
×
499
    }
500

501
    private void ToggleNodeCollapse(Entity node)
502
    {
503
        if (World.Has<GraphNodeCollapsed>(node))
×
504
        {
505
            // Expand: Remove the collapsed component
506
            World.Remove<GraphNodeCollapsed>(node);
×
507
        }
508
        else
509
        {
510
            // Collapse: Store the current height and add collapsed component
511
            ref readonly var nodeData = ref World.Get<GraphNode>(node);
×
512
            World.Add(node, new GraphNodeCollapsed { ExpandedHeight = nodeData.Height });
×
513
        }
514
    }
×
515

516
    private void DeleteSelected()
517
    {
518
        // Collect entities to delete (can't modify during iteration)
519
        var nodesToDelete = new List<Entity>();
×
520
        var connectionsToDelete = new List<Entity>();
×
521

522
        foreach (var node in World.Query<GraphNode, GraphNodeSelectedTag>())
×
523
        {
524
            nodesToDelete.Add(node);
×
525
        }
526

527
        foreach (var connection in World.Query<GraphConnection, GraphConnectionSelectedTag>())
×
528
        {
529
            connectionsToDelete.Add(connection);
×
530
        }
531

532
        // Use undoable methods
533
        if (nodesToDelete.Count > 0)
×
534
        {
535
            graphContext!.DeleteNodesUndoable(nodesToDelete);
×
536
        }
537

538
        foreach (var connection in connectionsToDelete)
×
539
        {
540
            graphContext!.DeleteConnectionUndoable(connection);
×
541
        }
542
    }
×
543

544
    private void HandleKeyboardShortcuts(Entity canvas, ref GraphCanvas canvasData, IKeyboard keyboard, Vector2 mousePos, Vector2 origin)
545
    {
546
        var ctrl = (keyboard.Modifiers & KeyModifiers.Control) != 0;
×
547

548
        // Delete/Backspace - delete selected
549
        if (WasKeyJustPressed(keyboard, Key.Delete) || WasKeyJustPressed(keyboard, Key.Backspace))
×
550
        {
551
            DeleteSelected();
×
552
            return;
×
553
        }
554

555
        // Ctrl+Z - Undo
556
        if (ctrl && WasKeyJustPressed(keyboard, Key.Z))
×
557
        {
558
            undoManager?.Undo();
×
559
            return;
×
560
        }
561

562
        // Ctrl+Y - Redo
563
        if (ctrl && WasKeyJustPressed(keyboard, Key.Y))
×
564
        {
565
            undoManager?.Redo();
×
566
            return;
×
567
        }
568

569
        // Ctrl+A - Select All
570
        if (ctrl && WasKeyJustPressed(keyboard, Key.A))
×
571
        {
572
            graphContext!.SelectAll(canvas);
×
573
            return;
×
574
        }
575

576
        // Ctrl+D - Duplicate
577
        if (ctrl && WasKeyJustPressed(keyboard, Key.D))
×
578
        {
579
            var selectedNodes = graphContext!.GetSelectedNodes().ToList();
×
580
            if (selectedNodes.Count > 0)
×
581
            {
582
                graphContext.DuplicateSelectionUndoable();
×
583
            }
584
            return;
×
585
        }
586

587
        // F - Frame Selection
588
        if (WasKeyJustPressed(keyboard, Key.F))
×
589
        {
590
            var selectedNodes = graphContext!.GetSelectedNodes().ToList();
×
591
            if (selectedNodes.Count > 0)
×
592
            {
593
                graphContext.FrameSelection(canvas);
×
594
            }
595
            return;
×
596
        }
597

598
        // Escape - Clear selection
599
        if (WasKeyJustPressed(keyboard, Key.Escape))
×
600
        {
601
            graphContext!.ClearSelection();
×
602
            return;
×
603
        }
604

605
        // Space - Space+drag panning (handled in mouse input, but we track the state here)
606
        if (keyboard.IsKeyDown(Key.Space) && !World.Has<GraphSpacePanningTag>(canvas))
×
607
        {
608
            World.Add(canvas, new GraphSpacePanningTag());
×
609
        }
610
        else if (!keyboard.IsKeyDown(Key.Space) && World.Has<GraphSpacePanningTag>(canvas))
×
611
        {
612
            World.Remove<GraphSpacePanningTag>(canvas);
×
613
        }
614

615
        // Right-click - Open context menu (handled in mouse logic, but check here)
616
        if (inputContext?.Mouse.IsButtonDown(MouseButton.Right) == true && WasKeyJustPressed(keyboard, Key.Unknown))
×
617
        {
618
            OpenContextMenu(canvas, ref canvasData, mousePos, origin);
×
619
        }
620
    }
×
621

622
    private void OpenContextMenu(Entity canvas, ref GraphCanvas canvasData, Vector2 screenPos, Vector2 origin)
623
    {
624
        var canvasPos = GraphTransform.ScreenToCanvas(screenPos, canvasData.Pan, canvasData.Zoom, origin);
×
625

626
        // Determine menu type based on what's under the cursor
627
        var hitNode = HitTestNodes(canvas, screenPos, canvasData.Pan, canvasData.Zoom, origin);
×
628

629
        ContextMenuType menuType;
630
        Entity targetEntity;
631

632
        if (hitNode.IsValid)
×
633
        {
634
            menuType = ContextMenuType.Node;
×
635
            targetEntity = hitNode;
×
636
        }
637
        else
638
        {
639
            menuType = ContextMenuType.Canvas;
×
640
            targetEntity = Entity.Null;
×
641
        }
642

643
        World.Add(canvas, new GraphContextMenu
×
644
        {
×
645
            ScreenPosition = screenPos,
×
646
            CanvasPosition = canvasPos,
×
647
            MenuType = menuType,
×
648
            TargetEntity = targetEntity,
×
649
            SearchFilter = string.Empty,
×
650
            SelectedIndex = 0
×
651
        });
×
652

653
        canvasData.Mode = GraphInteractionMode.ContextMenu;
×
654
    }
×
655

656
    private bool WasKeyJustPressed(IKeyboard keyboard, Key key)
657
    {
658
        var isDownNow = keyboard.IsKeyDown(key);
×
659
        var wasDownLastFrame = keysDownLastFrame.Contains(key);
×
660
        return isDownNow && !wasDownLastFrame;
×
661
    }
662

663
    private void UpdateKeyState(IKeyboard keyboard)
664
    {
665
        keysDownLastFrame.Clear();
×
666

667
        // Track all keys that are currently down
668
        for (var key = Key.Unknown; key <= Key.Slash; key++)
×
669
        {
670
            if (keyboard.IsKeyDown(key))
×
671
            {
672
                keysDownLastFrame.Add(key);
×
673
            }
674
        }
675
    }
×
676

677
    #region Port Interaction
678

679
    private void UpdateHoveredPort(Entity canvas, in GraphCanvas canvasData, Vector2 mousePos, Vector2 origin)
680
    {
681
        // Remove existing hover
682
        if (World.Has<HoveredPort>(canvas))
×
683
        {
684
            World.Remove<HoveredPort>(canvas);
×
685
        }
686

687
        if (portCache!.HitTestPort(mousePos, canvasData.Pan, canvasData.Zoom, origin,
×
688
            out var hitNode, out var hitDir, out var hitIndex))
×
689
        {
690
            // Get port type from registry
691
            if (!World.IsAlive(hitNode))
×
692
            {
693
                return;
×
694
            }
695

696
            ref readonly var node = ref World.Get<GraphNode>(hitNode);
×
697
            if (!portRegistry!.TryGetNodeType(node.NodeTypeId, out var nodeType))
×
698
            {
699
                return;
×
700
            }
701

702
            var ports = hitDir == PortDirection.Input ? nodeType.InputPorts : nodeType.OutputPorts;
×
703
            if (hitIndex >= ports.Length)
×
704
            {
705
                return;
×
706
            }
707

708
            var portDef = ports[hitIndex];
×
709
            var pos = portCache.GetPortPosition(hitNode, hitDir, hitIndex);
×
710

711
            World.Add(canvas, new HoveredPort
×
712
            {
×
713
                Node = hitNode,
×
714
                Direction = hitDir,
×
715
                PortIndex = hitIndex,
×
716
                TypeId = portDef.TypeId,
×
717
                Position = pos
×
718
            });
×
719
        }
720
    }
×
721

722
    private void StartConnection(
723
        Entity canvas,
724
        ref GraphCanvas canvasData,
725
        Entity node,
726
        PortDirection direction,
727
        int portIndex,
728
        Vector2 mousePos,
729
        Vector2 origin)
730
    {
731
        if (!World.IsAlive(node))
×
732
        {
733
            return;
×
734
        }
735

736
        ref readonly var nodeData = ref World.Get<GraphNode>(node);
×
737
        if (!portRegistry!.TryGetNodeType(nodeData.NodeTypeId, out var nodeType))
×
738
        {
739
            return;
×
740
        }
741

742
        var ports = direction == PortDirection.Input ? nodeType.InputPorts : nodeType.OutputPorts;
×
743
        if (portIndex >= ports.Length)
×
744
        {
745
            return;
×
746
        }
747

748
        var portDef = ports[portIndex];
×
749

750
        connectionSourceNode = node;
×
751
        connectionSourcePort = portIndex;
×
752
        connectionFromOutput = direction == PortDirection.Output;
×
753
        connectionSourceType = portDef.TypeId;
×
754
        isConnecting = true;
×
755

756
        canvasData.Mode = GraphInteractionMode.ConnectingPort;
×
757

758
        // Add pending connection component
759
        var canvasPos = GraphTransform.ScreenToCanvas(mousePos, canvasData.Pan, canvasData.Zoom, origin);
×
760
        World.Add(canvas, new PendingConnection
×
761
        {
×
762
            SourceNode = node,
×
763
            SourcePortIndex = portIndex,
×
764
            IsFromOutput = connectionFromOutput,
×
765
            CurrentPosition = canvasPos,
×
766
            SourceType = connectionSourceType
×
767
        });
×
768
    }
×
769

770
    private void UpdateConnection(
771
        Entity canvas,
772
        ref GraphCanvas canvasData,
773
        Vector2 mousePos,
774
        Vector2 origin,
775
        IMouse mouse,
776
        IKeyboard keyboard)
777
    {
778
        // Update preview position
779
        if (World.Has<PendingConnection>(canvas))
×
780
        {
781
            ref var pending = ref World.Get<PendingConnection>(canvas);
×
782
            pending.CurrentPosition = GraphTransform.ScreenToCanvas(
×
783
                mousePos, canvasData.Pan, canvasData.Zoom, origin);
×
784
        }
785

786
        // Cancel on ESC or right-click
787
        if (keyboard.IsKeyDown(Key.Escape) || mouse.IsButtonDown(MouseButton.Right))
×
788
        {
789
            CancelConnection(canvas, ref canvasData);
×
790
            return;
×
791
        }
792

793
        // Complete on left mouse release
794
        if (!mouse.IsButtonDown(MouseButton.Left))
×
795
        {
796
            CompleteConnection(canvas, ref canvasData, mousePos, origin);
×
797
        }
798
    }
×
799

800
    private void CompleteConnection(Entity canvas, ref GraphCanvas canvasData, Vector2 mousePos, Vector2 origin)
801
    {
802
        if (portCache!.HitTestPort(mousePos, canvasData.Pan, canvasData.Zoom, origin,
×
803
            out var targetNode, out var targetDir, out var targetIndex))
×
804
        {
805
            // Validate direction (output to input or input to output)
806
            var isValidDirection = connectionFromOutput
×
807
                ? targetDir == PortDirection.Input
×
808
                : targetDir == PortDirection.Output;
×
809

NEW
810
            if (isValidDirection && targetNode != connectionSourceNode && World.IsAlive(targetNode))
×
811
            {
812
                // Get target port type for validation
NEW
813
                ref readonly var targetNodeData = ref World.Get<GraphNode>(targetNode);
×
NEW
814
                if (portRegistry!.TryGetNodeType(targetNodeData.NodeTypeId, out var targetNodeType))
×
815
                {
NEW
816
                    var targetPorts = targetDir == PortDirection.Input
×
NEW
817
                        ? targetNodeType.InputPorts
×
NEW
818
                        : targetNodeType.OutputPorts;
×
819

NEW
820
                    if (targetIndex < targetPorts.Length)
×
821
                    {
NEW
822
                        var targetType = targetPorts[targetIndex].TypeId;
×
823

824
                        // Determine actual source and target based on drag direction
825
                        PortTypeId srcType, tgtType;
826
                        Entity srcNode, tgtNode;
827
                        int srcPort, tgtPort;
828

NEW
829
                        if (connectionFromOutput)
×
830
                        {
NEW
831
                            srcNode = connectionSourceNode;
×
NEW
832
                            srcPort = connectionSourcePort;
×
NEW
833
                            srcType = connectionSourceType;
×
NEW
834
                            tgtNode = targetNode;
×
NEW
835
                            tgtPort = targetIndex;
×
NEW
836
                            tgtType = targetType;
×
837
                        }
838
                        else
839
                        {
840
                            // Dragging from input - target is actually the source
NEW
841
                            srcNode = targetNode;
×
NEW
842
                            srcPort = targetIndex;
×
NEW
843
                            srcType = targetType;
×
NEW
844
                            tgtNode = connectionSourceNode;
×
NEW
845
                            tgtPort = connectionSourcePort;
×
NEW
846
                            tgtType = connectionSourceType;
×
847
                        }
848

849
                        // Validate type compatibility
NEW
850
                        if (PortTypeCompatibility.CanConnect(srcType, tgtType))
×
851
                        {
NEW
852
                            graphContext!.Connect(srcNode, srcPort, tgtNode, tgtPort);
×
853
                        }
854
                    }
855
                }
856
            }
857
        }
858

859
        CancelConnection(canvas, ref canvasData);
×
860
    }
×
861

862
    private void CancelConnection(Entity canvas, ref GraphCanvas canvasData)
863
    {
864
        isConnecting = false;
×
865
        connectionSourceNode = Entity.Null;
×
866
        canvasData.Mode = GraphInteractionMode.None;
×
867

868
        if (World.Has<PendingConnection>(canvas))
×
869
        {
870
            World.Remove<PendingConnection>(canvas);
×
871
        }
872
    }
×
873

874
    private Entity FindConnectionToInput(Entity node, int portIndex)
875
    {
876
        foreach (var connEntity in World.Query<GraphConnection>())
×
877
        {
878
            ref readonly var conn = ref World.Get<GraphConnection>(connEntity);
×
879
            if (conn.TargetNode == node && conn.TargetPortIndex == portIndex)
×
880
            {
881
                return connEntity;
×
882
            }
883
        }
884

885
        return Entity.Null;
×
886
    }
×
887

888
    #endregion
889
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc