feat(editor): properties-panel toggle + LabVIEW-style type colours

Two visible UX fixes:

1. Properties-panel toggle. Tap a step → opens. Tap the
   same step again → closes (deselect via toggle). Tap the
   canvas background → also closes. selectStep now returns
   to null when called with the already-selected id; the
   editor-controller test updated for the new semantics.

2. LabVIEW-style type colours on every port + wire.
   _typeAccent now picks vibrant brightness-aware hues per
   datatype:
     text   → magenta/pink (LabVIEW string)
     json   → amber/orange (cluster feel)
     bytes  → cyan/teal (raw binary)
     file   → green (file reference)
     number → yellow/amber
   Step-input dots, step-output dots, and outputs-endpoint
   dots all switch from the generic theme.primary to the
   field's declared type accent. Operators read the
   payload from the dot alone.

Editor tests still pass (20).

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-02 11:13:01 +02:00
parent 7b256b5e35
commit 1e9968496e
6 changed files with 133 additions and 39 deletions

View file

@ -149,10 +149,14 @@ class FlowEditorController extends ChangeNotifier {
notifyListeners(); 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) { void selectStep(String? stepId) {
if (_selectedStepId == stepId) return; final next = stepId != null && _selectedStepId == stepId ? null : stepId;
_selectedStepId = stepId; if (_selectedStepId == next) return;
_selectedStepId = next;
notifyListeners(); notifyListeners();
} }

View file

@ -617,11 +617,19 @@ outputs:
// overrides. Falls back to a balanced light/dark palette // overrides. Falls back to a balanced light/dark palette
// when the scheme tints don't read well as code colours. // when the scheme tints don't read well as code colours.
final keyAccent = cs.primary; final keyAccent = cs.primary;
final stringAccent = isDark ? const Color(0xFFA5D6A7) : const Color(0xFF2E7D32); final stringAccent = isDark
final numberAccent = isDark ? const Color(0xFFFFCC80) : const Color(0xFFE65100); ? const Color(0xFFA5D6A7)
final symbolAccent = isDark ? const Color(0xFFB39DDB) : const Color(0xFF6A1B9A); : 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 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 { return {
'root': monoBase.copyWith(color: cs.onSurface), 'root': monoBase.copyWith(color: cs.onSurface),
// YAML keys bold + primary accent so the structure // YAML keys bold + primary accent so the structure

View file

@ -471,6 +471,27 @@ class _FlowCanvasState extends State<FlowCanvas>
Positioned.fill( Positioned.fill(
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.translucent, 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) { onSecondaryTapDown: (details) {
final edge = _hitTestEdge( final edge = _hitTestEdge(
details.localPosition, details.localPosition,
@ -1507,16 +1528,21 @@ class _FlowCanvasState extends State<FlowCanvas>
for (final field in labels) { for (final field in labels) {
final p = _outputPortPosition(step.id, layout, fieldName: field); final p = _outputPortPosition(step.id, layout, fieldName: field);
final key = '${step.id}:$field'; final key = '${step.id}:$field';
// A specific output is "connected" iff some edge // Output port colour matches the declared field's
// actually leaves it. The drag handler stamps the // type so operators read the wire's payload from the
// edge's fromField so the new wire is bound to this // dot alone (LabVIEW convention). Falls back to the
// specific output rather than the legacy anchor. // 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( yield _portDot(
portKey: key, portKey: key,
center: p, center: p,
isSource: true, isSource: true,
connected: connectedPorts.contains(key), connected: connectedPorts.contains(key),
accent: Theme.of(context).colorScheme.primary, accent: accent,
onDragStart: () => _draft = _ConnectionDraft( onDragStart: () => _draft = _ConnectionDraft(
fromKind: _DraftSourceKind.step, fromKind: _DraftSourceKind.step,
fromId: step.id, fromId: step.id,
@ -1560,31 +1586,45 @@ class _FlowCanvasState extends State<FlowCanvas>
final field = labels[i]; final field = labels[i];
final value = step.with_[field]?.toString() ?? ''; final value = step.with_[field]?.toString() ?? '';
final wired = _isWiredExpression(value); 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( yield _portDot(
portKey: '${step.id}:$field', portKey: '${step.id}:$field',
center: p, center: p,
isSource: false, isSource: false,
connected: wired, connected: wired,
accent: Theme.of(context).colorScheme.primary, accent: accent,
onContextMenu: !wired onContextMenu: !wired
? null ? null
: (pos) => _disconnectInputPort(step.id, field, pos), : (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(); final outs = graph.outputs.keys.toList();
for (var i = 0; i < outs.length; i++) { for (var i = 0; i < outs.length; i++) {
final p = _inputPortPosition(AutoLayout.outputsNodeId, i, layout); final p = _inputPortPosition(AutoLayout.outputsNodeId, i, layout);
final field = outs[i]; final field = outs[i];
final expr = graph.outputs[field] ?? ''; final expr = graph.outputs[field] ?? '';
final wired = _isWiredExpression(expr); 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( yield _portDot(
portKey: 'outputs:$field', portKey: 'outputs:$field',
center: p, center: p,
isSource: false, isSource: false,
connected: wired, connected: wired,
accent: Theme.of(context).colorScheme.primary, accent: accent,
onContextMenu: !wired onContextMenu: !wired
? null ? null
: (pos) => _disconnectOutputPort(field, pos), : (pos) => _disconnectOutputPort(field, pos),
@ -1592,6 +1632,30 @@ class _FlowCanvasState extends State<FlowCanvas>
} }
} }
/// 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 /// True when [expression] looks like a `$src.field` ref
/// (the canonical wired-up form). Literal text or empty /// (the canonical wired-up form). Literal text or empty
/// values count as unwired. /// values count as unwired.
@ -1631,26 +1695,42 @@ class _FlowCanvasState extends State<FlowCanvas>
return set; 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 FI 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) { Color _typeAccent(String type, ThemeData theme) {
// Five distinct hues for the five payload types F-Delta-I final isDark = theme.brightness == Brightness.dark;
// 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.
switch (type) { switch (type) {
case 'text': case 'text':
return const Color(0xFF42A5F5); // blue // Magenta / pink LabVIEW's string colour. Pops on
case 'bytes': // both light + dark surfaces; high saturation reads
return const Color(0xFFFF7043); // deep orange // as "text flows here".
return isDark ? const Color(0xFFFF6FB5) : const Color(0xFFD81B60);
case 'json': 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': 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 'number':
case 'integer': case 'integer':
return const Color(0xFFFFCA28); // amber return isDark ? const Color(0xFFFFD54F) : const Color(0xFFF9A825);
default: default:
// Unknown type neutral, deliberately desaturated
// so the operator notices "I haven't typed this".
return theme.colorScheme.onSurfaceVariant; return theme.colorScheme.onSurfaceVariant;
} }
} }

View file

@ -101,11 +101,7 @@ class NodeGeometry {
/// last port row overflows. /// last port row overflows.
static double heightFor(int inputCount, [int outputCount = 0]) { static double heightFor(int inputCount, [int outputCount = 0]) {
final rows = inputCount > outputCount ? inputCount : outputCount; final rows = inputCount > outputCount ? inputCount : outputCount;
return headerHeight + return headerHeight + bodyTopPad + rows * portRowHeight + bodyBottomPad + 2;
bodyTopPad +
rows * portRowHeight +
bodyBottomPad +
2;
} }
/// Y offset (from the card's top edge) where the input /// Y offset (from the card's top edge) where the input

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.11.0 version: 0.12.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

View file

@ -69,7 +69,7 @@ outputs: {}
expect(c.isDirty, isFalse); 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(); final c = FlowEditorController();
addTearDown(c.dispose); addTearDown(c.dispose);
c.openFlow('demo', ''' c.openFlow('demo', '''
@ -80,14 +80,20 @@ outputs: {}
'''); ''');
var calls = 0; var calls = 0;
c.addListener(() => calls++); c.addListener(() => calls++);
// First select selectedStepId goes null 'only',
// listeners fire once.
c.selectStep('only'); c.selectStep('only');
final after = calls; expect(c.selectedStepId, 'only');
// Selecting the same thing again must NOT bump the expect(calls, 1);
// listener count the controller short-circuits. // Clicking the same step again deselects (toggle
// semantics): selectedStepId goes 'only' null,
// listeners fire again. That IS a state change.
c.selectStep('only'); 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); c.selectStep(null);
expect(calls, after + 1); expect(calls, 2);
}); });
}); });
} }