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:
parent
1e9968496e
commit
59aa8fe78e
5 changed files with 369 additions and 2 deletions
|
|
@ -94,6 +94,18 @@ class _PropertiesPanelState extends State<PropertiesPanel> {
|
|||
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<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 }
|
||||
|
||||
/// Editor for the flow's inputs / outputs endpoints. Lets the
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue