chain-studio-flow-editor/lib/src/editor_controller.dart
flemming-it 1e9968496e 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>
2026-06-02 11:13:01 +02:00

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();
});
}
}