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