feat(editor): type-token coloring + analyzer diagnostics

The text tab now colours `type: text|json|bytes|file|number`
values in the same hues the graph canvas uses for the wire of
that type — a glance at the YAML confirms what a glance at the
graph shows. Promoted the wire-colour palette to a single
source of truth in `wire_colors.dart` so the graph, the
properties panel and the text tab can't drift.

Adds `FlowAnalyzer` (extends `AbstractAnalyzer`) so the gutter
shows error pins and broken lines get wavy red underlines for:

  - YAML parse errors (source-span pinned to the offending line)
  - `use:` referencing a capability the operator hasn't installed
    (bare provider/name match — `text.echo@^1` and `text.echo`
    compare equal)

Editor host passes a closure into `setAvailableCapabilities` so
the analyser always sees the current installed list without
re-creating the controller on every Studio rebuild. Bumps the
package version to 0.15.0.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-04 00:46:51 +02:00
parent 73736769a0
commit 885d2db4e1
8 changed files with 400 additions and 52 deletions

View file

@ -17,9 +17,8 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_code_editor/flutter_code_editor.dart';
import 'package:highlight/languages/yaml.dart';
import 'flow_yaml_controller.dart';
import 'model/auto_layout.dart'; import 'model/auto_layout.dart';
import 'model/flow_graph.dart'; import 'model/flow_graph.dart';
import 'model/layout_store.dart'; import 'model/layout_store.dart';
@ -38,7 +37,7 @@ class FlowEditorController extends ChangeNotifier {
/// Underlying text controller the text tab's source of /// Underlying text controller the text tab's source of
/// truth. The graph + run tabs derive everything from this. /// truth. The graph + run tabs derive everything from this.
late final CodeController codeController; late final FlowYamlCodeController codeController;
/// Last-parsed graph. May be one debounce-tick behind the /// Last-parsed graph. May be one debounce-tick behind the
/// text buffer; never null after a flow is opened. /// text buffer; never null after a flow is opened.
@ -85,7 +84,7 @@ class FlowEditorController extends ChangeNotifier {
Timer? _reparseTimer; Timer? _reparseTimer;
FlowEditorController() { FlowEditorController() {
codeController = CodeController(text: '', language: yaml); codeController = FlowYamlCodeController();
codeController.addListener(_onCodeChanged); codeController.addListener(_onCodeChanged);
} }

View file

@ -0,0 +1,98 @@
// FlowAnalyzer feeds AbstractAnalyzer-shaped issues into the
// flow editor's CodeController so the gutter can show error
// pins and the text tab can underline broken lines.
//
// Two kinds of issues are reported today:
//
// - YAML parse errors. The exception's source span pins the
// line; the message goes straight through.
// - `use:` lines pointing at a capability the operator hasn't
// installed. We compare the bare `provider/name` (no
// version) against the host-supplied capability list, so
// `text.echo@^1` matches an installed `text.echo` even when
// the constraints differ.
//
// `$step.field` reference resolution and per-field type checks
// will land here next they need the module spec map and are
// staged in the editor controller plumbing in editor_controller.dart.
import 'package:flutter_code_editor/flutter_code_editor.dart';
import 'package:yaml/yaml.dart';
class FlowAnalyzer extends AbstractAnalyzer {
/// Returns the names of capabilities the operator has installed.
/// A closure so the analyzer always sees the current list
/// without needing a re-create on every Studio rebuild.
final List<String> Function() availableCapabilities;
const FlowAnalyzer({required this.availableCapabilities});
@override
Future<AnalysisResult> analyze(Code code) async {
final issues = <Issue>[];
final text = code.text;
if (text.trim().isEmpty) {
return const AnalysisResult(issues: []);
}
final caps = availableCapabilities();
final knownBareCaps = caps.map(_bareCap).toSet();
YamlNode? doc;
try {
doc = loadYamlNode(text);
} on YamlException catch (e) {
issues.add(
Issue(
line: e.span?.start.line ?? 0,
message: 'YAML: ${e.message}',
type: IssueType.error,
),
);
return AnalysisResult(issues: issues);
} catch (e) {
issues.add(
Issue(
line: 0,
message: 'YAML: $e',
type: IssueType.error,
),
);
return AnalysisResult(issues: issues);
}
if (doc is YamlMap) {
final steps = doc['steps'];
if (steps is YamlList) {
for (final step in steps.nodes) {
if (step is! YamlMap) continue;
final useNode = step.nodes['use'];
if (useNode == null) continue;
final useValue = useNode.value;
if (useValue is! String) continue;
if (knownBareCaps.isEmpty) continue;
if (!knownBareCaps.contains(_bareCap(useValue))) {
issues.add(
Issue(
line: useNode.span.start.line,
message:
'Unknown capability "$useValue". '
'Install the module that provides it, or check the spelling.',
type: IssueType.error,
),
);
}
}
}
}
return AnalysisResult(issues: issues);
}
}
/// Strip the `@<version>` tail from a capability spec so
/// `text.echo@^1` and `text.echo` compare equal.
String _bareCap(String full) {
final at = full.indexOf('@');
return at < 0 ? full : full.substring(0, at);
}

View file

@ -100,6 +100,9 @@ class _FlowEditorPageState extends State<FlowEditorPage>
super.initState(); super.initState();
_l = FlowEditorStrings(widget.locale); _l = FlowEditorStrings(widget.locale);
_controller = FlowEditorController(); _controller = FlowEditorController();
_controller.codeController.setAvailableCapabilities(
() => widget.availableCapabilities,
);
_controller.addListener(_onCtrlChanged); _controller.addListener(_onCtrlChanged);
_tabs = TabController(length: 3, vsync: this); _tabs = TabController(length: 3, vsync: this);
_files = _listFiles(); _files = _listFiles();

View file

@ -0,0 +1,242 @@
// FlowYamlCodeController CodeController subclass that adds
// FI-specific colouring on top of the stock YAML highlighter.
//
// The base highlighter colours generic YAML structure (keys,
// strings, numbers, comments, anchors). On top of that, this
// subclass walks the rendered span tree once per build to find
// `type: text|json|bytes|file|number|integer` declarations and
// recolours just the type-value token with the same hue the
// graph canvas uses for that wire so a glance at the YAML
// confirms what a glance at the graph shows.
//
// The lookup runs every keystroke (because TextField rebuilds
// the span on each change), but each pass is O(N) over the
// visible text fast enough for the flow files we expect
// (hundreds of lines, not megabytes).
import 'package:flutter/material.dart';
import 'package:flutter_code_editor/flutter_code_editor.dart';
import 'package:highlight/languages/yaml.dart';
import 'flow_analyzer.dart';
import 'wire_colors.dart';
class FlowYamlCodeController extends CodeController {
FlowYamlCodeController({List<String> Function()? availableCapabilities})
: super(
text: '',
language: yaml,
analyzer: FlowAnalyzer(
availableCapabilities:
availableCapabilities ?? (() => const <String>[]),
),
);
/// Replace the analyzer's capability provider. Called by the
/// editor host when the installed-capability list changes
/// e.g. after `fai install` while the editor is open.
void setAvailableCapabilities(List<String> Function() provider) {
analyzer = FlowAnalyzer(availableCapabilities: provider);
}
// Matches `type:` followed by (optional) quote + one of our
// known type names + (optional) closing quote. The captured
// group is the type name only we use the parent match span
// to locate it inside the document so we can recolour just
// the name (not the `type:` key itself).
static final RegExp _typeAssignment = RegExp(
r'''\btype\s*:\s*(?:['"])?([A-Za-z_][\w-]*)(?:['"])?''',
);
@override
TextSpan buildTextSpan({
required BuildContext context,
TextStyle? style,
bool? withComposing,
}) {
final base = super.buildTextSpan(
context: context,
style: style,
withComposing: withComposing,
);
final theme = Theme.of(context);
final overrides = <_Override>[];
overrides.addAll(_collectOverrides(text, theme));
overrides.addAll(_collectIssueOverrides(theme));
if (overrides.isEmpty) return base;
overrides.sort((a, b) => a.start.compareTo(b.start));
return _applyOverrides(base, overrides);
}
/// Build wavy-underline overrides for every analyzer-reported
/// issue. The underline spans from the first non-whitespace
/// character to the end of the affected line, so a clean line
/// stays clean and the diagnostic visually attaches to the
/// content the operator wrote (not the indent).
List<_Override> _collectIssueOverrides(ThemeData theme) {
final issues = analysisResult.issues;
if (issues.isEmpty) return const <_Override>[];
final lines = code.lines.lines;
final out = <_Override>[];
for (final issue in issues) {
if (issue.line < 0 || issue.line >= lines.length) continue;
final line = lines[issue.line];
final text = line.text;
// Skip trailing newline if any so the underline doesn't
// extend past the visual line end.
var endOffset = line.textRange.end;
if (text.endsWith('\n')) endOffset -= 1;
var startOffset = line.textRange.start;
// Walk past leading whitespace so indentation isn't
// underlined looks cleaner and keeps focus on the
// actual broken token.
for (var i = 0; i < text.length; i++) {
final c = text.codeUnitAt(i);
if (c == 0x20 || c == 0x09) {
startOffset++;
} else {
break;
}
}
if (startOffset >= endOffset) continue;
final color = switch (issue.type) {
IssueType.error => theme.colorScheme.error,
IssueType.warning => const Color(0xFFEF6C00),
IssueType.info => theme.colorScheme.primary,
};
out.add(
_Override(
startOffset,
endOffset,
TextStyle(
decoration: TextDecoration.underline,
decorationStyle: TextDecorationStyle.wavy,
decorationColor: color,
decorationThickness: 1.5,
),
),
);
}
return out;
}
/// Scan [source] and return ascending, non-overlapping ranges
/// whose [_Override.style] should override whatever the
/// highlighter chose.
List<_Override> _collectOverrides(String source, ThemeData theme) {
final out = <_Override>[];
for (final m in _typeAssignment.allMatches(source)) {
final typeName = m.group(1);
if (typeName == null || !kKnownTypes.contains(typeName)) continue;
final whole = m.group(0)!;
final nameStart = m.start + whole.indexOf(typeName);
final nameEnd = nameStart + typeName.length;
out.add(
_Override(
nameStart,
nameEnd,
TextStyle(
color: wireColor(typeName, theme),
fontWeight: FontWeight.w600,
),
),
);
}
return out;
}
/// Flatten + rebuild [span] so every character in an
/// [_Override] range carries the override style. Outside
/// ranges, the original highlighter style is preserved.
TextSpan _applyOverrides(InlineSpan span, List<_Override> overrides) {
final leaves = <_Leaf>[];
_flatten(span, null, leaves);
final children = <InlineSpan>[];
var globalCursor = 0;
var ovIdx = 0;
for (final leaf in leaves) {
final localText = leaf.text;
final leafStart = globalCursor;
final leafEnd = leafStart + localText.length;
var localCursor = 0;
while (ovIdx < overrides.length && overrides[ovIdx].end <= leafStart) {
ovIdx++;
}
var probe = ovIdx;
while (probe < overrides.length && overrides[probe].start < leafEnd) {
final ov = overrides[probe];
final ovStart = ov.start < leafStart ? leafStart : ov.start;
final ovEnd = ov.end > leafEnd ? leafEnd : ov.end;
final before = ovStart - leafStart;
if (before > localCursor) {
children.add(
TextSpan(
text: localText.substring(localCursor, before),
style: leaf.style,
),
);
}
final overlap = ovEnd - leafStart;
children.add(
TextSpan(
text: localText.substring(before, overlap),
style: (leaf.style ?? const TextStyle()).merge(ov.style),
),
);
localCursor = overlap;
probe++;
}
if (localCursor < localText.length) {
children.add(
TextSpan(
text: localText.substring(localCursor),
style: leaf.style,
),
);
}
globalCursor = leafEnd;
}
final rootStyle = (span is TextSpan) ? span.style : null;
return TextSpan(style: rootStyle, children: children);
}
/// Depth-first walk of [span], emitting one [_Leaf] per text
/// chunk in document order. The highlighter's SpanBuilder
/// produces TextSpans that carry BOTH text AND children; we
/// emit the parent's text first, then descend into its
/// children, so concatenating all leaves reproduces [text].
void _flatten(InlineSpan span, TextStyle? inherited, List<_Leaf> out) {
if (span is! TextSpan) return;
final merged = inherited == null
? span.style
: (span.style == null ? inherited : inherited.merge(span.style));
final t = span.text;
if (t != null && t.isNotEmpty) {
out.add(_Leaf(t, merged));
}
final children = span.children;
if (children != null) {
for (final c in children) {
_flatten(c, merged, out);
}
}
}
}
class _Override {
final int start;
final int end;
final TextStyle style;
const _Override(this.start, this.end, this.style);
}
class _Leaf {
final String text;
final TextStyle? style;
const _Leaf(this.text, this.style);
}

View file

@ -42,6 +42,7 @@ import '../model/flow_graph.dart';
import '../model/layout_store.dart'; import '../model/layout_store.dart';
import '../run_driver.dart'; import '../run_driver.dart';
import '../tokens.dart'; import '../tokens.dart';
import '../wire_colors.dart';
import 'edge_painter.dart'; import 'edge_painter.dart';
import 'flow_node.dart'; import 'flow_node.dart';
@ -1714,38 +1715,7 @@ class _FlowCanvasState extends State<FlowCanvas>
/// five FI types stay distinguishable both at glance /// five FI types stay distinguishable both at glance
/// (saturation differences) and for colour-blind operators /// (saturation differences) and for colour-blind operators
/// (different luminance, not only different hue). /// (different luminance, not only different hue).
Color _typeAccent(String type, ThemeData theme) { Color _typeAccent(String type, ThemeData theme) => wireColor(type, theme);
final isDark = theme.brightness == Brightness.dark;
switch (type) {
case 'text':
// Magenta / pink LabVIEW's string colour. Pops on
// both light + dark surfaces; high saturation reads
// as "text flows here".
return isDark ? const Color(0xFFFF6FB5) : const Color(0xFFD81B60);
case 'json':
// 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':
// 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 'integer':
return isDark ? const Color(0xFFFFD54F) : const Color(0xFFF9A825);
default:
// Unknown type neutral, deliberately desaturated
// so the operator notices "I haven't typed this".
return theme.colorScheme.onSurfaceVariant;
}
}
Future<void> _disconnectInputPort( Future<void> _disconnectInputPort(
String stepId, String stepId,

View file

@ -19,6 +19,7 @@ import '../l10n.dart';
import '../model/auto_layout.dart'; import '../model/auto_layout.dart';
import '../model/flow_graph.dart'; import '../model/flow_graph.dart';
import '../tokens.dart'; import '../tokens.dart';
import '../wire_colors.dart';
class PropertiesPanel extends StatefulWidget { class PropertiesPanel extends StatefulWidget {
final FlowEditorController controller; final FlowEditorController controller;
@ -673,21 +674,7 @@ class _EdgeInfoView extends StatelessWidget {
return null; return null;
} }
Color _typeColor(String type, ThemeData theme) { Color _typeColor(String type, ThemeData theme) => wireColor(type, 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) { void _disconnect(FlowEdge edge) {
final graph = controller.graph; final graph = controller.graph;

49
lib/src/wire_colors.dart Normal file
View file

@ -0,0 +1,49 @@
// Wire / type colour palette shared by the graph canvas, the
// properties panel, and the YAML text editor.
//
// Centralised here so a single source of truth governs every
// surface where the operator sees a typed value: the wire
// painting on the graph tab, the type pill in the properties
// drawer, and the `type:` token highlight in the text tab.
//
// Hues are picked from a LabVIEW-inspired palette high
// saturation for clear visual separation, with day/night
// variants that stay readable on the FaiTheme surface colours.
import 'package:flutter/material.dart';
/// Colour for a FI declared data type (`text`, `json`, `bytes`,
/// `file`, `number`/`integer`). Falls back to the theme's
/// neutral [onSurfaceVariant] when [type] is unknown that's
/// the "operator forgot to declare a type" signal, deliberately
/// muted so it nudges without shouting.
Color wireColor(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);
case 'number':
case 'integer':
return isDark ? const Color(0xFFFFD54F) : const Color(0xFFF9A825);
default:
return theme.colorScheme.onSurfaceVariant;
}
}
/// The set of FI type tokens we colour in the YAML text editor.
/// Kept in sync with [wireColor]'s switch arms so the graph wire
/// and the text-tab `type: foo` value always render the same hue.
const Set<String> kKnownTypes = {
'text',
'json',
'bytes',
'file',
'number',
'integer',
};

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.14.0 version: 0.15.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