diff --git a/lib/src/editor_controller.dart b/lib/src/editor_controller.dart index de180a0..cafebfc 100644 --- a/lib/src/editor_controller.dart +++ b/lib/src/editor_controller.dart @@ -149,10 +149,14 @@ class FlowEditorController extends ChangeNotifier { notifyListeners(); } - /// User clicked a node on the canvas. + /// User clicked a node on the canvas. Clicking the + /// already-selected step deselects it (toggle semantics) — + /// matches every modern node editor. Pass null explicitly + /// to deselect without re-clicking (e.g. background tap). void selectStep(String? stepId) { - if (_selectedStepId == stepId) return; - _selectedStepId = stepId; + final next = stepId != null && _selectedStepId == stepId ? null : stepId; + if (_selectedStepId == next) return; + _selectedStepId = next; notifyListeners(); } diff --git a/lib/src/flow_editor_page.dart b/lib/src/flow_editor_page.dart index e7a58c3..d1e1344 100644 --- a/lib/src/flow_editor_page.dart +++ b/lib/src/flow_editor_page.dart @@ -617,11 +617,19 @@ outputs: // overrides. Falls back to a balanced light/dark palette // when the scheme tints don't read well as code colours. final keyAccent = cs.primary; - final stringAccent = isDark ? const Color(0xFFA5D6A7) : const Color(0xFF2E7D32); - final numberAccent = isDark ? const Color(0xFFFFCC80) : const Color(0xFFE65100); - final symbolAccent = isDark ? const Color(0xFFB39DDB) : const Color(0xFF6A1B9A); + final stringAccent = isDark + ? const Color(0xFFA5D6A7) + : const Color(0xFF2E7D32); + final numberAccent = isDark + ? const Color(0xFFFFCC80) + : const Color(0xFFE65100); + final symbolAccent = isDark + ? const Color(0xFFB39DDB) + : const Color(0xFF6A1B9A); final commentAccent = cs.onSurfaceVariant.withValues(alpha: 0.7); - final metaAccent = isDark ? const Color(0xFF80DEEA) : const Color(0xFF00838F); + final metaAccent = isDark + ? const Color(0xFF80DEEA) + : const Color(0xFF00838F); return { 'root': monoBase.copyWith(color: cs.onSurface), // YAML keys — bold + primary accent so the structure diff --git a/lib/src/widgets/flow_canvas.dart b/lib/src/widgets/flow_canvas.dart index 463f582..e473c0e 100644 --- a/lib/src/widgets/flow_canvas.dart +++ b/lib/src/widgets/flow_canvas.dart @@ -471,6 +471,27 @@ class _FlowCanvasState extends State Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.translucent, + // Tap on the canvas background (no node, + // no port, no edge) deselects the active + // step — closes the properties panel. + // Tapping a node's hit area is consumed + // by the node's own GestureDetector + // (opaque), so this fires only on misses. + // Edges shadow the same hit-test footprint + // via _hitTestEdge; if the cursor was over + // an edge we let the edge keep its hover / + // context-menu treatment instead of + // deselecting. + onTapUp: (details) { + final edge = _hitTestEdge( + details.localPosition, + graph, + layout, + ); + if (edge == null) { + widget.controller.selectStep(null); + } + }, onSecondaryTapDown: (details) { final edge = _hitTestEdge( details.localPosition, @@ -1507,16 +1528,21 @@ class _FlowCanvasState extends State for (final field in labels) { final p = _outputPortPosition(step.id, layout, fieldName: field); final key = '${step.id}:$field'; - // A specific output is "connected" iff some edge - // actually leaves it. The drag handler stamps the - // edge's fromField so the new wire is bound to this - // specific output rather than the legacy anchor. + // Output port colour matches the declared field's + // type so operators read the wire's payload from the + // dot alone (LabVIEW convention). Falls back to the + // theme's primary when the field's type can't be + // resolved (legacy v1/v2 manifest, hub not loaded). + final type = _stepOutputType(step, field); + final accent = type != null + ? _typeAccent(type, Theme.of(context)) + : Theme.of(context).colorScheme.primary; yield _portDot( portKey: key, center: p, isSource: true, connected: connectedPorts.contains(key), - accent: Theme.of(context).colorScheme.primary, + accent: accent, onDragStart: () => _draft = _ConnectionDraft( fromKind: _DraftSourceKind.step, fromId: step.id, @@ -1560,31 +1586,45 @@ class _FlowCanvasState extends State final field = labels[i]; final value = step.with_[field]?.toString() ?? ''; final wired = _isWiredExpression(value); + // Step-input dot wears the declared field's type + // colour so a `bytes` slot reads differently from a + // `text` slot at a glance. + final type = _stepInputType(step, field); + final accent = type != null + ? _typeAccent(type, Theme.of(context)) + : Theme.of(context).colorScheme.primary; yield _portDot( portKey: '${step.id}:$field', center: p, isSource: false, connected: wired, - accent: Theme.of(context).colorScheme.primary, + accent: accent, onContextMenu: !wired ? null : (pos) => _disconnectInputPort(step.id, field, pos), ); } } - // Outputs endpoint input ports — same disconnect treatment. + // Outputs endpoint input ports — colour by the upstream + // type when the YAML's $step.field expression resolves + // to a step output whose type we know. Otherwise stays + // the theme's primary so the dot is still visible. final outs = graph.outputs.keys.toList(); for (var i = 0; i < outs.length; i++) { final p = _inputPortPosition(AutoLayout.outputsNodeId, i, layout); final field = outs[i]; final expr = graph.outputs[field] ?? ''; final wired = _isWiredExpression(expr); + final upstreamType = _outputsEndpointType(field, graph); + final accent = upstreamType != null + ? _typeAccent(upstreamType, Theme.of(context)) + : Theme.of(context).colorScheme.primary; yield _portDot( portKey: 'outputs:$field', center: p, isSource: false, connected: wired, - accent: Theme.of(context).colorScheme.primary, + accent: accent, onContextMenu: !wired ? null : (pos) => _disconnectOutputPort(field, pos), @@ -1592,6 +1632,30 @@ class _FlowCanvasState extends State } } + /// Resolve the upstream type for an outputs-endpoint slot. + /// Walks the edges into the outputs endpoint; when the + /// source is a step whose ModuleSpec exposes the field's + /// type, returns it. When source is an inputs endpoint + /// (pass-through), returns the FlowInput's declared type. + String? _outputsEndpointType(String field, FlowGraph graph) { + for (final edge in graph.edges) { + if (edge.toKind != EdgeEndpointKind.outputs) continue; + if (edge.toField != field) continue; + if (edge.fromKind == EdgeEndpointKind.inputs) { + return graph.inputs[edge.fromField]?.type; + } + if (edge.fromKind == EdgeEndpointKind.step) { + final step = graph.steps.firstWhere( + (s) => s.id == edge.fromId, + orElse: () => const FlowStep(id: '__missing__', use: ''), + ); + if (step.id == '__missing__') return null; + return _stepOutputType(step, edge.fromField); + } + } + return null; + } + /// True when [expression] looks like a `$src.field` ref /// (the canonical wired-up form). Literal text or empty /// values count as unwired. @@ -1631,26 +1695,42 @@ class _FlowCanvasState extends State return set; } + /// Datatype → port + wire accent. Inspired by LabVIEW's + /// long-running convention (string → pink/magenta, cluster + /// → brown, numeric → amber/orange) where each type carries + /// a sticky colour you learn after one flow. Tuned so the + /// five F∆I types stay distinguishable both at glance + /// (saturation differences) and for colour-blind operators + /// (different luminance, not only different hue). Color _typeAccent(String type, ThemeData theme) { - // Five distinct hues for the five payload types F-Delta-I - // flows declare today. Picked from a high-contrast set - // that survives both light and dark themes; intentionally - // NOT pulled exclusively from the theme's primary / - // secondary / tertiary slots because those collide once - // a custom theme plugin is active. + final isDark = theme.brightness == Brightness.dark; switch (type) { case 'text': - return const Color(0xFF42A5F5); // blue - case 'bytes': - return const Color(0xFFFF7043); // deep orange + // Magenta / pink — LabVIEW's string colour. Pops on + // both light + dark surfaces; high saturation reads + // as "text flows here". + return isDark ? const Color(0xFFFF6FB5) : const Color(0xFFD81B60); case 'json': - return const Color(0xFFAB47BC); // purple + // Amber / orange — structured-data colour, evokes + // LabVIEW's cluster brown. Reads as "compound payload". + return isDark ? const Color(0xFFFFB74D) : const Color(0xFFEF6C00); + case 'bytes': + // Cyan / teal — raw binary. Distinct from string-pink + // and from the green of "file reference" so the + // operator never confuses "I'm sending raw bytes" with + // "I'm sending a file handle". + return isDark ? const Color(0xFF4DD0E1) : const Color(0xFF00838F); case 'file': - return const Color(0xFF66BB6A); // green + // Green — file reference / resource handle. LabVIEW + // uses green for refnums; reads as "this is a pointer + // to something on disk". + return isDark ? const Color(0xFF81C784) : const Color(0xFF2E7D32); case 'number': case 'integer': - return const Color(0xFFFFCA28); // amber + return isDark ? const Color(0xFFFFD54F) : const Color(0xFFF9A825); default: + // Unknown type — neutral, deliberately desaturated + // so the operator notices "I haven't typed this". return theme.colorScheme.onSurfaceVariant; } } diff --git a/lib/src/widgets/flow_node.dart b/lib/src/widgets/flow_node.dart index 249ab73..3bb15be 100644 --- a/lib/src/widgets/flow_node.dart +++ b/lib/src/widgets/flow_node.dart @@ -101,11 +101,7 @@ class NodeGeometry { /// last port row overflows. static double heightFor(int inputCount, [int outputCount = 0]) { final rows = inputCount > outputCount ? inputCount : outputCount; - return headerHeight + - bodyTopPad + - rows * portRowHeight + - bodyBottomPad + - 2; + return headerHeight + bodyTopPad + rows * portRowHeight + bodyBottomPad + 2; } /// Y offset (from the card's top edge) where the input diff --git a/pubspec.yaml b/pubspec.yaml index e96aa9b..65a7647 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: fai_studio_flow_editor description: Swappable inline YAML editor for F∆I Studio flows. -version: 0.11.0 +version: 0.12.0 publish_to: 'none' repository: https://git.flemming.ai/fai/studio-flow-editor diff --git a/test/editor_controller_test.dart b/test/editor_controller_test.dart index ca4f7d0..e21e6bf 100644 --- a/test/editor_controller_test.dart +++ b/test/editor_controller_test.dart @@ -69,7 +69,7 @@ outputs: {} expect(c.isDirty, isFalse); }); - test('selectStep notifies listeners only on change', () { + test('selectStep notifies on change + toggles off on same-id click', () { final c = FlowEditorController(); addTearDown(c.dispose); c.openFlow('demo', ''' @@ -80,14 +80,20 @@ outputs: {} '''); var calls = 0; c.addListener(() => calls++); + // First select — selectedStepId goes null → 'only', + // listeners fire once. c.selectStep('only'); - final after = calls; - // Selecting the same thing again must NOT bump the - // listener count — the controller short-circuits. + expect(c.selectedStepId, 'only'); + expect(calls, 1); + // Clicking the same step again deselects (toggle + // semantics): selectedStepId goes 'only' → null, + // listeners fire again. That IS a state change. c.selectStep('only'); - expect(calls, after); + expect(c.selectedStepId, null); + expect(calls, 2); + // Explicit null while already null is a no-op. c.selectStep(null); - expect(calls, after + 1); + expect(calls, 2); }); }); }