feat(editor): edge selection + properties-panel edge-info + canvas-wide deselect

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 <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-02 15:22:46 +02:00
parent 1e9968496e
commit 59aa8fe78e
5 changed files with 369 additions and 2 deletions

View file

@ -153,10 +153,36 @@ class FlowEditorController extends ChangeNotifier {
/// already-selected step deselects it (toggle semantics) /// already-selected step deselects it (toggle semantics)
/// matches every modern node editor. Pass null explicitly /// matches every modern node editor. Pass null explicitly
/// to deselect without re-clicking (e.g. background tap). /// 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) { void selectStep(String? stepId) {
final next = stepId != null && _selectedStepId == stepId ? null : stepId; final next = stepId != null && _selectedStepId == stepId ? null : stepId;
if (_selectedStepId == next) return; final edgeChange = _selectedEdgeKey != null;
if (_selectedStepId == next && !edgeChange) return;
_selectedStepId = next; _selectedStepId = next;
if (next != null) _selectedEdgeKey = null;
notifyListeners();
}
/// Active edge selection `<toId>:<toField>` (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(); notifyListeners();
} }

View file

@ -84,6 +84,20 @@ class FlowEditorStrings {
String get propRemoveInput => _t('Remove input', 'Eingabe entfernen'); String get propRemoveInput => _t('Remove input', 'Eingabe entfernen');
String get propDeleteStep => _t('Delete step', 'Schritt löschen'); 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. // Capability picker.
String get pickerTitle => _t('Pick a capability', 'Capability auswählen'); String get pickerTitle => _t('Pick a capability', 'Capability auswählen');
String get pickerSearch => _t('Search…', 'Suchen…'); String get pickerSearch => _t('Search…', 'Suchen…');

View file

@ -429,6 +429,26 @@ class _FlowCanvasState extends State<FlowCanvas>
: BoxDecoration(color: theme.colorScheme.surface), : BoxDecoration(color: theme.colorScheme.surface),
child: Stack( child: Stack(
children: [ 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( InteractiveViewer(
transformationController: _transform, transformationController: _transform,
constrained: false, constrained: false,
@ -489,7 +509,18 @@ class _FlowCanvasState extends State<FlowCanvas>
layout, layout,
); );
if (edge == null) { if (edge == null) {
// Empty canvas tap close
// properties panel (both step + edge
// selections clear).
widget.controller.selectStep(null); 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) { onSecondaryTapDown: (details) {
@ -1066,6 +1097,7 @@ class _FlowCanvasState extends State<FlowCanvas>
final edgeKey = '${edge.toId}:${edge.toField}'; final edgeKey = '${edge.toId}:${edge.toField}';
final highlight = final highlight =
_hoveredEdge == edgeKey || _hoveredEdge == edgeKey ||
widget.controller.selectedEdgeKey == edgeKey ||
edge.fromId == widget.controller.selectedStepId || edge.fromId == widget.controller.selectedStepId ||
edge.toId == widget.controller.selectedStepId; edge.toId == widget.controller.selectedStepId;
// Type-aware wire colours at both endpoints. Inputs- // Type-aware wire colours at both endpoints. Inputs-

View file

@ -94,6 +94,18 @@ class _PropertiesPanelState extends State<PropertiesPanel> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final controller = widget.controller; 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; final selected = controller.selectedStepId;
if (selected == null) return _emptyHint(theme); if (selected == null) return _emptyHint(theme);
// Endpoint nodes (inputs / outputs) get their own // Endpoint nodes (inputs / outputs) get their own
@ -422,6 +434,289 @@ class _PropertiesPanelState extends State<PropertiesPanel> {
} }
} }
/// 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 `<toId>:<toField>` (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 } enum _EndpointKind { inputs, outputs }
/// Editor for the flow's inputs / outputs endpoints. Lets the /// Editor for the flow's inputs / outputs endpoints. Lets the

View file

@ -1,6 +1,6 @@
name: fai_studio_flow_editor name: fai_studio_flow_editor
description: Swappable inline YAML editor for F∆I Studio flows. description: Swappable inline YAML editor for F∆I Studio flows.
version: 0.12.0 version: 0.13.0
publish_to: 'none' publish_to: 'none'
repository: https://git.flemming.ai/fai/studio-flow-editor repository: https://git.flemming.ai/fai/studio-flow-editor