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>
225 lines
7.7 KiB
Dart
225 lines
7.7 KiB
Dart
// FlowEditorController — shared state for the three tabs.
|
|
//
|
|
// The Graph, Text, and Run tabs all observe one controller.
|
|
// The controller owns:
|
|
//
|
|
// - The active flow's name + YAML text + last-saved baseline.
|
|
// - The parsed FlowGraph (kept in sync with the text buffer).
|
|
// - The FlowLayout sidecar (node positions).
|
|
// - Selection (which node is highlighted).
|
|
// - Dirty + saving + running flags.
|
|
//
|
|
// All three tabs read state via [ChangeNotifier] and call
|
|
// mutators (`setText`, `applyGraphEdit`, …). The controller
|
|
// debounces text -> graph reparsing so typing in the text
|
|
// tab doesn't thrash the canvas.
|
|
|
|
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_code_editor/flutter_code_editor.dart';
|
|
import 'package:highlight/languages/yaml.dart';
|
|
|
|
import 'model/auto_layout.dart';
|
|
import 'model/flow_graph.dart';
|
|
import 'model/layout_store.dart';
|
|
|
|
/// Mirrors `FlowNodeStatus` from the flow_node widget, but
|
|
/// lives in the controller so the run tab can update it
|
|
/// without importing widgets. The canvas adapts between
|
|
/// the two.
|
|
enum StepRunStatus { idle, running, done, failed, awaiting }
|
|
|
|
class FlowEditorController extends ChangeNotifier {
|
|
/// Active flow file name (without `.yaml`). `null` while
|
|
/// the editor is empty.
|
|
String? get activeName => _activeName;
|
|
String? _activeName;
|
|
|
|
/// Underlying text controller — the text tab's source of
|
|
/// truth. The graph + run tabs derive everything from this.
|
|
late final CodeController codeController;
|
|
|
|
/// Last-parsed graph. May be one debounce-tick behind the
|
|
/// text buffer; never null after a flow is opened.
|
|
FlowGraph get graph => _graph;
|
|
FlowGraph _graph = FlowGraph.empty();
|
|
|
|
/// Persistent node positions. Layout-only edits (drag a
|
|
/// node) update this immediately and write to the sidecar.
|
|
FlowLayout get layout => _layout;
|
|
FlowLayout _layout = FlowLayout.empty();
|
|
|
|
/// Step id currently highlighted in the canvas + targeted
|
|
/// by the properties panel. `null` when nothing is selected.
|
|
String? get selectedStepId => _selectedStepId;
|
|
String? _selectedStepId;
|
|
|
|
/// Baseline YAML captured immediately after loading a flow
|
|
/// from disk (or saving). `dirty` is the difference between
|
|
/// the buffer and this baseline. Comparing _code.fullText
|
|
/// against this baseline (not against the raw on-disk text)
|
|
/// avoids the false-positive prompt the controller's own
|
|
/// normalisation used to trigger.
|
|
String _baseline = '';
|
|
|
|
bool get isDirty =>
|
|
_activeName != null && codeController.fullText != _baseline;
|
|
bool get isSaving => _saving;
|
|
bool _saving = false;
|
|
bool get isRunning => _running;
|
|
bool _running = false;
|
|
|
|
/// Live per-step status for the in-flight or most-recent
|
|
/// run. The canvas pulls colours and icons out of this map
|
|
/// so the graph view shows real-time progress even when
|
|
/// the operator triggered the run from the run tab.
|
|
Map<String, StepRunStatus> get stepStatuses =>
|
|
Map.unmodifiable(_stepStatuses);
|
|
final Map<String, StepRunStatus> _stepStatuses = {};
|
|
|
|
/// Flag flipped during graph -> YAML emission so we don't
|
|
/// loop: the text change listener skips a reparse if it's
|
|
/// already mid-emit.
|
|
bool _emittingFromGraph = false;
|
|
Timer? _reparseTimer;
|
|
|
|
FlowEditorController() {
|
|
codeController = CodeController(text: '', language: yaml);
|
|
codeController.addListener(_onCodeChanged);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_reparseTimer?.cancel();
|
|
codeController.removeListener(_onCodeChanged);
|
|
codeController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
/// Open a flow into the controller. Sets the text, parses
|
|
/// the graph, loads the layout sidecar, and resets the
|
|
/// baseline so `dirty` starts at false.
|
|
void openFlow(String name, String yamlText) {
|
|
_activeName = name;
|
|
_emittingFromGraph = true;
|
|
codeController.fullText = yamlText;
|
|
_emittingFromGraph = false;
|
|
_baseline = codeController.fullText;
|
|
final parsed = FlowGraph.tryParse(_baseline) ?? FlowGraph.empty();
|
|
_graph = parsed;
|
|
_layout = LayoutStore.loadSync(name);
|
|
// Fill any missing positions deterministically so the
|
|
// first open of a flow doesn't show a blank canvas.
|
|
_layout = AutoLayout.layout(_graph, _layout);
|
|
LayoutStore.saveSync(name, _layout);
|
|
_selectedStepId = null;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Mark the buffer as saved — call right after writing to
|
|
/// disk so `dirty` returns to false until the next edit.
|
|
void markSaved() {
|
|
_baseline = codeController.fullText;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// External-state hooks for the host (Studio sets these via
|
|
/// callbacks during run / save).
|
|
set saving(bool v) {
|
|
if (_saving == v) return;
|
|
_saving = v;
|
|
notifyListeners();
|
|
}
|
|
|
|
set running(bool v) {
|
|
if (_running == v) return;
|
|
_running = v;
|
|
if (v) {
|
|
_stepStatuses.clear();
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Update the live status of one step. Called by the run
|
|
/// tab as events arrive from the host's FlowRunDriver.
|
|
void updateStepStatus(String stepId, StepRunStatus status) {
|
|
if (_stepStatuses[stepId] == status) return;
|
|
_stepStatuses[stepId] = status;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// 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) {
|
|
final next = stepId != null && _selectedStepId == stepId ? null : stepId;
|
|
if (_selectedStepId == next) return;
|
|
_selectedStepId = next;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Persist a node's new position and write the sidecar.
|
|
/// Layout-only — does not mutate the YAML, does not affect
|
|
/// `dirty`.
|
|
void moveStep(String stepId, NodePosition position) {
|
|
_layout = _layout.withPosition(stepId, position);
|
|
if (_activeName != null) {
|
|
LayoutStore.saveSync(_activeName!, _layout);
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Wipe every position in the sidecar and re-run auto-
|
|
/// layout from scratch. Useful when manual drags have
|
|
/// drifted into spaghetti and the operator wants a clean
|
|
/// topological column layout again. The YAML is untouched.
|
|
void resetLayout() {
|
|
_layout = AutoLayout.layout(_graph, FlowLayout.empty());
|
|
if (_activeName != null) {
|
|
LayoutStore.saveSync(_activeName!, _layout);
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
/// Apply a graph-side edit (rename step, change `use`,
|
|
/// adjust `with`, add/remove step, add edge). Emits fresh
|
|
/// YAML into the text controller, then bumps the layout to
|
|
/// include any newly-added step.
|
|
void applyGraphEdit(FlowGraph next) {
|
|
_graph = next;
|
|
_layout = AutoLayout.layout(next, _layout);
|
|
if (_activeName != null) {
|
|
LayoutStore.saveSync(_activeName!, _layout);
|
|
}
|
|
_emittingFromGraph = true;
|
|
codeController.fullText = next.toYaml();
|
|
_emittingFromGraph = false;
|
|
notifyListeners();
|
|
}
|
|
|
|
void _onCodeChanged() {
|
|
if (_emittingFromGraph) return;
|
|
// Debounce 350ms — fast enough that the canvas feels
|
|
// responsive while the operator pauses between
|
|
// keystrokes, slow enough that each frame of typing
|
|
// doesn't trigger a fresh parse + AutoLayout pass.
|
|
_reparseTimer?.cancel();
|
|
_reparseTimer = Timer(const Duration(milliseconds: 350), () {
|
|
final parsed = FlowGraph.tryParse(codeController.fullText);
|
|
if (parsed == null) {
|
|
// Keep the last-good graph so the canvas stays
|
|
// visible while the operator is mid-typo.
|
|
notifyListeners();
|
|
return;
|
|
}
|
|
_graph = parsed;
|
|
_layout = AutoLayout.layout(parsed, _layout);
|
|
if (_activeName != null) {
|
|
LayoutStore.saveSync(_activeName!, _layout);
|
|
}
|
|
notifyListeners();
|
|
});
|
|
}
|
|
}
|