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

orion-ecs / keen-eye / 20871273650

10 Jan 2026 02:23AM UTC coverage: 86.895% (-0.07%) from 86.962%
20871273650

push

github

tyevco
fix(ui): Fix UI widget tests for arrows, TabView visibility, and TextInput

- Use Unicode arrows (▼/▶) instead of ASCII (v/>) for accordion and tree view expand/collapse indicators
- Set visibility, UITabPanel, and UIHiddenTag on both scrollView and scrollContent in CreateTabView
- Fix UITextInput test assertions to expect ShowingPlaceholder=false when no placeholder text is provided

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

9264 of 12568 branches covered (73.71%)

Branch coverage included in aggregate %.

15 of 15 new or added lines in 6 files covered. (100.0%)

321 existing lines in 16 files now uncovered.

159741 of 181925 relevant lines covered (87.81%)

1.0 hits per line

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

0.63
/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

441
                if (selectionBox.Intersects(screenRect) && !World.Has<GraphNodeSelectedTag>(node))
×
442
                {
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
    // Keys that the graph system actually cares about
UNCOV
664
    private static readonly Key[] trackedKeys =
×
665
    [
×
UNCOV
666
        // Modifier keys for multi-select
×
UNCOV
667
        Key.LeftControl,
×
668
        Key.RightControl,
×
UNCOV
669
        Key.LeftShift,
×
670
        Key.RightShift,
×
UNCOV
671
        // Action keys
×
672
        Key.Delete,
×
UNCOV
673
        Key.Escape,
×
UNCOV
674
        Key.A,  // For Ctrl+A select all
×
675
        Key.C,  // For Ctrl+C copy
×
UNCOV
676
        Key.V,  // For Ctrl+V paste
×
UNCOV
677
        Key.X,  // For Ctrl+X cut
×
UNCOV
678
        Key.Z,  // For Ctrl+Z undo
×
UNCOV
679
        Key.Y,  // For Ctrl+Y redo
×
UNCOV
680
    ];
×
681

682
    private void UpdateKeyState(IKeyboard keyboard)
683
    {
684
        keysDownLastFrame.Clear();
×
685

686
        // Only track keys that are relevant to graph editing
687
        foreach (var key in trackedKeys)
×
688
        {
UNCOV
689
            if (keyboard.IsKeyDown(key))
×
690
            {
691
                keysDownLastFrame.Add(key);
×
692
            }
693
        }
UNCOV
694
    }
×
695

696
    #region Port Interaction
697

698
    private void UpdateHoveredPort(Entity canvas, in GraphCanvas canvasData, Vector2 mousePos, Vector2 origin)
699
    {
700
        // Remove existing hover
UNCOV
701
        if (World.Has<HoveredPort>(canvas))
×
702
        {
703
            World.Remove<HoveredPort>(canvas);
×
704
        }
705

UNCOV
706
        if (portCache!.HitTestPort(mousePos, canvasData.Pan, canvasData.Zoom, origin,
×
UNCOV
707
            out var hitNode, out var hitDir, out var hitIndex))
×
708
        {
709
            // Get port type from registry
UNCOV
710
            if (!World.IsAlive(hitNode))
×
711
            {
712
                return;
×
713
            }
714

715
            ref readonly var node = ref World.Get<GraphNode>(hitNode);
×
716
            if (!portRegistry!.TryGetNodeType(node.NodeTypeId, out var nodeType))
×
717
            {
718
                return;
×
719
            }
720

UNCOV
721
            var ports = hitDir == PortDirection.Input ? nodeType.InputPorts : nodeType.OutputPorts;
×
UNCOV
722
            if (hitIndex >= ports.Length)
×
723
            {
UNCOV
724
                return;
×
725
            }
726

UNCOV
727
            var portDef = ports[hitIndex];
×
UNCOV
728
            var pos = portCache.GetPortPosition(hitNode, hitDir, hitIndex);
×
729

UNCOV
730
            World.Add(canvas, new HoveredPort
×
731
            {
×
UNCOV
732
                Node = hitNode,
×
733
                Direction = hitDir,
×
UNCOV
734
                PortIndex = hitIndex,
×
UNCOV
735
                TypeId = portDef.TypeId,
×
736
                Position = pos
×
737
            });
×
738
        }
739
    }
×
740

741
    private void StartConnection(
742
        Entity canvas,
743
        ref GraphCanvas canvasData,
744
        Entity node,
745
        PortDirection direction,
746
        int portIndex,
747
        Vector2 mousePos,
748
        Vector2 origin)
749
    {
750
        if (!World.IsAlive(node))
×
751
        {
752
            return;
×
753
        }
754

UNCOV
755
        ref readonly var nodeData = ref World.Get<GraphNode>(node);
×
756
        if (!portRegistry!.TryGetNodeType(nodeData.NodeTypeId, out var nodeType))
×
757
        {
UNCOV
758
            return;
×
759
        }
760

761
        var ports = direction == PortDirection.Input ? nodeType.InputPorts : nodeType.OutputPorts;
×
762
        if (portIndex >= ports.Length)
×
763
        {
764
            return;
×
765
        }
766

767
        var portDef = ports[portIndex];
×
768

UNCOV
769
        connectionSourceNode = node;
×
UNCOV
770
        connectionSourcePort = portIndex;
×
UNCOV
771
        connectionFromOutput = direction == PortDirection.Output;
×
UNCOV
772
        connectionSourceType = portDef.TypeId;
×
UNCOV
773
        isConnecting = true;
×
774

UNCOV
775
        canvasData.Mode = GraphInteractionMode.ConnectingPort;
×
776

777
        // Add pending connection component
UNCOV
778
        var canvasPos = GraphTransform.ScreenToCanvas(mousePos, canvasData.Pan, canvasData.Zoom, origin);
×
779
        World.Add(canvas, new PendingConnection
×
UNCOV
780
        {
×
781
            SourceNode = node,
×
782
            SourcePortIndex = portIndex,
×
783
            IsFromOutput = connectionFromOutput,
×
UNCOV
784
            CurrentPosition = canvasPos,
×
UNCOV
785
            SourceType = connectionSourceType
×
UNCOV
786
        });
×
787
    }
×
788

789
    private void UpdateConnection(
790
        Entity canvas,
791
        ref GraphCanvas canvasData,
792
        Vector2 mousePos,
793
        Vector2 origin,
794
        IMouse mouse,
795
        IKeyboard keyboard)
796
    {
797
        // Update preview position
798
        if (World.Has<PendingConnection>(canvas))
×
799
        {
UNCOV
800
            ref var pending = ref World.Get<PendingConnection>(canvas);
×
UNCOV
801
            pending.CurrentPosition = GraphTransform.ScreenToCanvas(
×
802
                mousePos, canvasData.Pan, canvasData.Zoom, origin);
×
803
        }
804

805
        // Cancel on ESC or right-click
806
        if (keyboard.IsKeyDown(Key.Escape) || mouse.IsButtonDown(MouseButton.Right))
×
807
        {
808
            CancelConnection(canvas, ref canvasData);
×
UNCOV
809
            return;
×
810
        }
811

812
        // Complete on left mouse release
813
        if (!mouse.IsButtonDown(MouseButton.Left))
×
814
        {
UNCOV
815
            CompleteConnection(canvas, ref canvasData, mousePos, origin);
×
816
        }
817
    }
×
818

819
    private void CompleteConnection(Entity canvas, ref GraphCanvas canvasData, Vector2 mousePos, Vector2 origin)
820
    {
UNCOV
821
        if (portCache!.HitTestPort(mousePos, canvasData.Pan, canvasData.Zoom, origin,
×
822
            out var targetNode, out var targetDir, out var targetIndex))
×
823
        {
824
            // Validate direction (output to input or input to output)
UNCOV
825
            var isValidDirection = connectionFromOutput
×
UNCOV
826
                ? targetDir == PortDirection.Input
×
UNCOV
827
                : targetDir == PortDirection.Output;
×
828

829
            if (isValidDirection && targetNode != connectionSourceNode && World.IsAlive(targetNode))
×
830
            {
831
                // Get target port type for validation
832
                ref readonly var targetNodeData = ref World.Get<GraphNode>(targetNode);
×
833
                if (portRegistry!.TryGetNodeType(targetNodeData.NodeTypeId, out var targetNodeType))
×
834
                {
835
                    var targetPorts = targetDir == PortDirection.Input
×
836
                        ? targetNodeType.InputPorts
×
UNCOV
837
                        : targetNodeType.OutputPorts;
×
838

UNCOV
839
                    if (targetIndex < targetPorts.Length)
×
840
                    {
841
                        var targetType = targetPorts[targetIndex].TypeId;
×
842

843
                        // Determine actual source and target based on drag direction
844
                        PortTypeId srcType, tgtType;
845
                        Entity srcNode, tgtNode;
846
                        int srcPort, tgtPort;
847

UNCOV
848
                        if (connectionFromOutput)
×
849
                        {
850
                            srcNode = connectionSourceNode;
×
UNCOV
851
                            srcPort = connectionSourcePort;
×
852
                            srcType = connectionSourceType;
×
UNCOV
853
                            tgtNode = targetNode;
×
UNCOV
854
                            tgtPort = targetIndex;
×
UNCOV
855
                            tgtType = targetType;
×
856
                        }
857
                        else
858
                        {
859
                            // Dragging from input - target is actually the source
860
                            srcNode = targetNode;
×
UNCOV
861
                            srcPort = targetIndex;
×
UNCOV
862
                            srcType = targetType;
×
UNCOV
863
                            tgtNode = connectionSourceNode;
×
864
                            tgtPort = connectionSourcePort;
×
865
                            tgtType = connectionSourceType;
×
866
                        }
867

868
                        // Validate type compatibility
UNCOV
869
                        if (PortTypeCompatibility.CanConnect(srcType, tgtType))
×
870
                        {
UNCOV
871
                            graphContext!.Connect(srcNode, srcPort, tgtNode, tgtPort);
×
872
                        }
873
                    }
874
                }
875
            }
876
        }
877

878
        CancelConnection(canvas, ref canvasData);
×
879
    }
×
880

881
    private void CancelConnection(Entity canvas, ref GraphCanvas canvasData)
882
    {
UNCOV
883
        isConnecting = false;
×
UNCOV
884
        connectionSourceNode = Entity.Null;
×
885
        canvasData.Mode = GraphInteractionMode.None;
×
886

UNCOV
887
        if (World.Has<PendingConnection>(canvas))
×
888
        {
UNCOV
889
            World.Remove<PendingConnection>(canvas);
×
890
        }
UNCOV
891
    }
×
892

893
    private Entity FindConnectionToInput(Entity node, int portIndex)
894
    {
UNCOV
895
        foreach (var connEntity in World.Query<GraphConnection>())
×
896
        {
UNCOV
897
            ref readonly var conn = ref World.Get<GraphConnection>(connEntity);
×
UNCOV
898
            if (conn.TargetNode == node && conn.TargetPortIndex == portIndex)
×
899
            {
UNCOV
900
                return connEntity;
×
901
            }
902
        }
903

UNCOV
904
        return Entity.Null;
×
UNCOV
905
    }
×
906

907
    #endregion
908
}
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