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:
parent
7b256b5e35
commit
1e9968496e
6 changed files with 133 additions and 39 deletions
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 F∆I 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue