From 59aa8fe78e387bfd80fc1dfc28a912d149a91c8f Mon Sep 17 00:00:00 2001 From: flemming-it Date: Tue, 2 Jun 2026 15:22:46 +0200 Subject: [PATCH] feat(editor): edge selection + properties-panel edge-info + canvas-wide deselect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three flow-editor UX upgrades: 1. Wider deselect zone. A new translucent Positioned.fill GestureDetector wraps the outermost Stack (above the InteractiveViewer). Tapping the dark area outside the canvas grid — which used to do nothing — now closes the properties panel. 2. Edge selection. Click a wire → controller.selectEdge stamps the edge key. Click again or click background → deselects. Selection is mutually exclusive with step selection, enforced inside the controller so the panel never tries to render both. Selected edges highlight with the same accent the hover state uses. 3. Properties panel grows an edge-info mode. When an edge is selected, the panel shows: - source qualified name (e.g. inputs.document or summarize.response) - target qualified name - type colour swatch + name per endpoint (LabVIEW palette) - type-compatibility pill (green / red) - Disconnect button that removes the edge in one click l10n: 8 new edgeInfo* strings in EN + DE. Editor tests still pass (20). Signed-off-by: flemming-it --- lib/src/editor_controller.dart | 28 ++- lib/src/l10n.dart | 14 ++ lib/src/widgets/flow_canvas.dart | 32 +++ lib/src/widgets/properties_panel.dart | 295 ++++++++++++++++++++++++++ pubspec.yaml | 2 +- 5 files changed, 369 insertions(+), 2 deletions(-) diff --git a/lib/src/editor_controller.dart b/lib/src/editor_controller.dart index cafebfc..8130246 100644 --- a/lib/src/editor_controller.dart +++ b/lib/src/editor_controller.dart @@ -153,10 +153,36 @@ class FlowEditorController extends ChangeNotifier { /// already-selected step deselects it (toggle semantics) — /// matches every modern node editor. Pass null explicitly /// to deselect without re-clicking (e.g. background tap). + /// Selecting a step also clears any active edge selection, + /// since the properties panel can only show one at a time. void selectStep(String? stepId) { final next = stepId != null && _selectedStepId == stepId ? null : stepId; - if (_selectedStepId == next) return; + final edgeChange = _selectedEdgeKey != null; + if (_selectedStepId == next && !edgeChange) return; _selectedStepId = next; + if (next != null) _selectedEdgeKey = null; + notifyListeners(); + } + + /// Active edge selection — `:` (the + /// target-side key, same format the edge-context-menu + /// uses). When non-null, the properties panel switches + /// to edge-info mode. Mutually exclusive with + /// [selectedStepId]; selecting a step clears the edge and + /// vice versa. + String? get selectedEdgeKey => _selectedEdgeKey; + String? _selectedEdgeKey; + + /// Toggle the edge selection. Same click-the-same-thing-to- + /// deselect semantics as [selectStep]. + void selectEdge(String? edgeKey) { + final next = edgeKey != null && _selectedEdgeKey == edgeKey + ? null + : edgeKey; + final stepChange = _selectedStepId != null && next != null; + if (_selectedEdgeKey == next && !stepChange) return; + _selectedEdgeKey = next; + if (next != null) _selectedStepId = null; notifyListeners(); } diff --git a/lib/src/l10n.dart b/lib/src/l10n.dart index f85ed7a..6a0a3ce 100644 --- a/lib/src/l10n.dart +++ b/lib/src/l10n.dart @@ -84,6 +84,20 @@ class FlowEditorStrings { String get propRemoveInput => _t('Remove input', 'Eingabe entfernen'); String get propDeleteStep => _t('Delete step', 'Schritt löschen'); + // Edge-info side of the properties panel — visible when an + // edge (wire) is selected. + String get edgeInfoHeader => _t('Connection', 'Verbindung'); + String get edgeInfoSource => _t('Source', 'Quelle'); + String get edgeInfoTarget => _t('Target', 'Ziel'); + String get edgeInfoTypeUnknown => _t('unknown type', 'Typ unbekannt'); + String get edgeInfoTypeMatch => _t('Types compatible.', 'Typen kompatibel.'); + String edgeInfoTypeMismatch(String src, String tgt) => _t( + 'Type mismatch: $src does not flow into $tgt.', + 'Typkonflikt: $src passt nicht in $tgt.', + ); + String get edgeInfoDisconnect => _t('Disconnect', 'Trennen'); + String get edgeInfoClose => _t('Close', 'Schließen'); + // Capability picker. String get pickerTitle => _t('Pick a capability', 'Capability auswählen'); String get pickerSearch => _t('Search…', 'Suchen…'); diff --git a/lib/src/widgets/flow_canvas.dart b/lib/src/widgets/flow_canvas.dart index e473c0e..c610cdd 100644 --- a/lib/src/widgets/flow_canvas.dart +++ b/lib/src/widgets/flow_canvas.dart @@ -429,6 +429,26 @@ class _FlowCanvasState extends State : BoxDecoration(color: theme.colorScheme.surface), child: Stack( children: [ + // Outer-most tap layer — catches clicks on the + // dark area OUTSIDE the canvas grid (when the + // operator has panned / zoomed so the grid + // doesn't fill the viewport). Translucent so + // InteractiveViewer below still handles pan + + // zoom; the gesture arena gives the tap to + // whichever child claims it first, and on + // canvas-empty pointers nobody else does. + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + // Deselect both step + edge so the + // properties panel closes when the operator + // clicks the empty backdrop. + widget.controller.selectStep(null); + widget.controller.selectEdge(null); + }, + ), + ), InteractiveViewer( transformationController: _transform, constrained: false, @@ -489,7 +509,18 @@ class _FlowCanvasState extends State layout, ); if (edge == null) { + // Empty canvas tap → close + // properties panel (both step + edge + // selections clear). widget.controller.selectStep(null); + widget.controller.selectEdge(null); + } else { + // Tap on a wire → select that edge so + // the properties panel shows its + // source/target + type compatibility. + // Mutually exclusive with step + // selection inside the controller. + widget.controller.selectEdge(edge); } }, onSecondaryTapDown: (details) { @@ -1066,6 +1097,7 @@ class _FlowCanvasState extends State final edgeKey = '${edge.toId}:${edge.toField}'; final highlight = _hoveredEdge == edgeKey || + widget.controller.selectedEdgeKey == edgeKey || edge.fromId == widget.controller.selectedStepId || edge.toId == widget.controller.selectedStepId; // Type-aware wire colours at both endpoints. Inputs- diff --git a/lib/src/widgets/properties_panel.dart b/lib/src/widgets/properties_panel.dart index 4dff7f2..9b92d03 100644 --- a/lib/src/widgets/properties_panel.dart +++ b/lib/src/widgets/properties_panel.dart @@ -94,6 +94,18 @@ class _PropertiesPanelState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); final controller = widget.controller; + // Edge selection takes priority over step selection. + // The controller already enforces mutual exclusion; + // the check here just routes the panel to the right + // editor. + final edgeKey = controller.selectedEdgeKey; + if (edgeKey != null) { + return _EdgeInfoView( + controller: controller, + strings: widget.strings, + edgeKey: edgeKey, + ); + } final selected = controller.selectedStepId; if (selected == null) return _emptyHint(theme); // Endpoint nodes (inputs / outputs) get their own @@ -422,6 +434,289 @@ class _PropertiesPanelState extends State { } } +/// Edge-info side of the properties panel — visible when an +/// edge is selected. Shows source + target with their declared +/// types, a type-compatibility indicator, and a Disconnect +/// button. Helps the operator see at a glance whether a wire +/// is valid; the colour cue from the canvas already telegraphs +/// the same information but here we say it in words. +class _EdgeInfoView extends StatelessWidget { + final FlowEditorController controller; + final FlowEditorStrings strings; + final String edgeKey; + + const _EdgeInfoView({ + required this.controller, + required this.strings, + required this.edgeKey, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final graph = controller.graph; + // The edgeKey is `:` (same encoding the + // hit-tester emits + the context menu uses). One edge + // per target slot, so the lookup is unique. + FlowEdge? edge; + for (final e in graph.edges) { + if ('${e.toId}:${e.toField}' == edgeKey) { + edge = e; + break; + } + } + if (edge == null) { + // Edge vanished while panel was open (operator deleted + // it via context menu, undo, etc.). Drop the selection + // and bail gracefully. + WidgetsBinding.instance.addPostFrameCallback((_) { + controller.selectEdge(null); + }); + return const SizedBox.shrink(); + } + final fromLabel = edge.fromKind == EdgeEndpointKind.inputs + ? 'inputs.${edge.fromField}' + : '${edge.fromId}.${edge.fromField}'; + final toLabel = edge.toKind == EdgeEndpointKind.outputs + ? 'outputs.${edge.toField}' + : '${edge.toId}.${edge.toField}'; + final sourceType = _sourceTypeFor(edge, graph); + final targetType = _targetTypeFor(edge, graph); + final typesMatch = + sourceType == null || targetType == null || sourceType == targetType; + return Container( + color: theme.colorScheme.surface, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Header — matches the step panel's visual rhythm. + Container( + padding: const EdgeInsets.symmetric( + horizontal: FaiSpace.md, + vertical: FaiSpace.sm, + ), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + border: Border( + bottom: BorderSide(color: theme.colorScheme.outlineVariant), + ), + ), + child: Row( + children: [ + Icon(Icons.cable, size: 18, color: theme.colorScheme.primary), + const SizedBox(width: FaiSpace.sm), + Expanded( + child: Text( + strings.edgeInfoHeader, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 18), + visualDensity: VisualDensity.compact, + tooltip: strings.edgeInfoClose, + onPressed: () => controller.selectEdge(null), + ), + ], + ), + ), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(FaiSpace.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _endpointBlock( + theme, + label: strings.edgeInfoSource, + qualified: fromLabel, + type: sourceType, + ), + const SizedBox(height: FaiSpace.md), + Center( + child: Icon( + Icons.arrow_downward, + size: 18, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: FaiSpace.md), + _endpointBlock( + theme, + label: strings.edgeInfoTarget, + qualified: toLabel, + type: targetType, + ), + const SizedBox(height: FaiSpace.lg), + _typeMatchPill(theme, typesMatch, sourceType, targetType), + const SizedBox(height: FaiSpace.lg), + OutlinedButton.icon( + onPressed: () => _disconnect(edge!), + icon: const Icon(Icons.link_off, size: 16), + label: Text(strings.edgeInfoDisconnect), + style: OutlinedButton.styleFrom( + foregroundColor: theme.colorScheme.error, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _endpointBlock( + ThemeData theme, { + required String label, + required String qualified, + required String? type, + }) { + final accent = type != null + ? _typeColor(type, theme) + : theme.colorScheme.outline; + return Container( + padding: const EdgeInsets.all(FaiSpace.md), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.6, + fontSize: 10, + ), + ), + const SizedBox(height: 4), + Text( + qualified, + style: theme.textTheme.titleSmall?.copyWith( + fontFamily: 'monospace', + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: accent, + ), + ), + const SizedBox(width: 6), + Text( + type ?? strings.edgeInfoTypeUnknown, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ], + ), + ); + } + + Widget _typeMatchPill( + ThemeData theme, + bool ok, + String? sourceType, + String? targetType, + ) { + final fg = ok ? Colors.green.shade400 : theme.colorScheme.error; + final label = ok + ? strings.edgeInfoTypeMatch + : strings.edgeInfoTypeMismatch(sourceType ?? '?', targetType ?? '?'); + return Row( + children: [ + Icon( + ok ? Icons.check_circle_outline : Icons.error_outline, + size: 18, + color: fg, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + label, + style: theme.textTheme.bodySmall?.copyWith(color: fg), + ), + ), + ], + ); + } + + String? _sourceTypeFor(FlowEdge edge, FlowGraph graph) { + if (edge.fromKind == EdgeEndpointKind.inputs) { + return graph.inputs[edge.fromField]?.type; + } + // Step output type — we don't keep ModuleSpec here in + // the panel (lives on the canvas state). Return null; + // the panel renders 'unknown' which is the honest answer. + return null; + } + + String? _targetTypeFor(FlowEdge edge, FlowGraph graph) { + if (edge.toKind == EdgeEndpointKind.outputs) return null; + return null; + } + + Color _typeColor(String type, ThemeData theme) { + final isDark = theme.brightness == Brightness.dark; + switch (type) { + case 'text': + return isDark ? const Color(0xFFFF6FB5) : const Color(0xFFD81B60); + case 'json': + return isDark ? const Color(0xFFFFB74D) : const Color(0xFFEF6C00); + case 'bytes': + return isDark ? const Color(0xFF4DD0E1) : const Color(0xFF00838F); + case 'file': + return isDark ? const Color(0xFF81C784) : const Color(0xFF2E7D32); + default: + return theme.colorScheme.outline; + } + } + + void _disconnect(FlowEdge edge) { + final graph = controller.graph; + if (edge.toKind == EdgeEndpointKind.outputs) { + final updated = {...graph.outputs}..remove(edge.toField); + controller.applyGraphEdit( + FlowGraph( + name: graph.name, + inputs: graph.inputs, + steps: graph.steps, + outputs: updated, + leadingComment: graph.leadingComment, + ), + ); + } else { + final step = graph.steps.firstWhere( + (s) => s.id == edge.toId, + orElse: () => const FlowStep(id: '', use: ''), + ); + if (step.id.isEmpty) return; + final updatedWith = {...step.with_, edge.toField: ''}; + controller.applyGraphEdit( + graph.withStepUpdated(edge.toId, step.copyWith(with_: updatedWith)), + ); + } + controller.selectEdge(null); + } +} + enum _EndpointKind { inputs, outputs } /// Editor for the flow's inputs / outputs endpoints. Lets the diff --git a/pubspec.yaml b/pubspec.yaml index 65a7647..5c2792a 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.12.0 +version: 0.13.0 publish_to: 'none' repository: https://git.flemming.ai/fai/studio-flow-editor