From 885d2db4e1402578953e4f13288121a181abf3f3 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Thu, 4 Jun 2026 00:46:51 +0200 Subject: [PATCH] feat(editor): type-token coloring + analyzer diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/src/editor_controller.dart | 7 +- lib/src/flow_analyzer.dart | 98 +++++++++++ lib/src/flow_editor_page.dart | 3 + lib/src/flow_yaml_controller.dart | 242 ++++++++++++++++++++++++++ lib/src/widgets/flow_canvas.dart | 34 +--- lib/src/widgets/properties_panel.dart | 17 +- lib/src/wire_colors.dart | 49 ++++++ pubspec.yaml | 2 +- 8 files changed, 400 insertions(+), 52 deletions(-) create mode 100644 lib/src/flow_analyzer.dart create mode 100644 lib/src/flow_yaml_controller.dart create mode 100644 lib/src/wire_colors.dart diff --git a/lib/src/editor_controller.dart b/lib/src/editor_controller.dart index 8130246..fb80dfc 100644 --- a/lib/src/editor_controller.dart +++ b/lib/src/editor_controller.dart @@ -17,9 +17,8 @@ import 'dart:async'; 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/flow_graph.dart'; import 'model/layout_store.dart'; @@ -38,7 +37,7 @@ class FlowEditorController extends ChangeNotifier { /// Underlying text controller — the text tab's source of /// 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 /// text buffer; never null after a flow is opened. @@ -85,7 +84,7 @@ class FlowEditorController extends ChangeNotifier { Timer? _reparseTimer; FlowEditorController() { - codeController = CodeController(text: '', language: yaml); + codeController = FlowYamlCodeController(); codeController.addListener(_onCodeChanged); } diff --git a/lib/src/flow_analyzer.dart b/lib/src/flow_analyzer.dart new file mode 100644 index 0000000..bb22e2c --- /dev/null +++ b/lib/src/flow_analyzer.dart @@ -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 Function() availableCapabilities; + + const FlowAnalyzer({required this.availableCapabilities}); + + @override + Future analyze(Code code) async { + final issues = []; + 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 `@` 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); +} diff --git a/lib/src/flow_editor_page.dart b/lib/src/flow_editor_page.dart index 354d4e8..d2a9baf 100644 --- a/lib/src/flow_editor_page.dart +++ b/lib/src/flow_editor_page.dart @@ -100,6 +100,9 @@ class _FlowEditorPageState extends State super.initState(); _l = FlowEditorStrings(widget.locale); _controller = FlowEditorController(); + _controller.codeController.setAvailableCapabilities( + () => widget.availableCapabilities, + ); _controller.addListener(_onCtrlChanged); _tabs = TabController(length: 3, vsync: this); _files = _listFiles(); diff --git a/lib/src/flow_yaml_controller.dart b/lib/src/flow_yaml_controller.dart new file mode 100644 index 0000000..2f6d815 --- /dev/null +++ b/lib/src/flow_yaml_controller.dart @@ -0,0 +1,242 @@ +// FlowYamlCodeController — CodeController subclass that adds +// F∆I-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 Function()? availableCapabilities}) + : super( + text: '', + language: yaml, + analyzer: FlowAnalyzer( + availableCapabilities: + availableCapabilities ?? (() => const []), + ), + ); + + /// 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 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 = []; + 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); +} diff --git a/lib/src/widgets/flow_canvas.dart b/lib/src/widgets/flow_canvas.dart index 454c86e..9303a7e 100644 --- a/lib/src/widgets/flow_canvas.dart +++ b/lib/src/widgets/flow_canvas.dart @@ -42,6 +42,7 @@ import '../model/flow_graph.dart'; import '../model/layout_store.dart'; import '../run_driver.dart'; import '../tokens.dart'; +import '../wire_colors.dart'; import 'edge_painter.dart'; import 'flow_node.dart'; @@ -1714,38 +1715,7 @@ class _FlowCanvasState extends State /// 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) { - 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; - } - } + Color _typeAccent(String type, ThemeData theme) => wireColor(type, theme); Future _disconnectInputPort( String stepId, diff --git a/lib/src/widgets/properties_panel.dart b/lib/src/widgets/properties_panel.dart index 9b92d03..e3e0f99 100644 --- a/lib/src/widgets/properties_panel.dart +++ b/lib/src/widgets/properties_panel.dart @@ -19,6 +19,7 @@ import '../l10n.dart'; import '../model/auto_layout.dart'; import '../model/flow_graph.dart'; import '../tokens.dart'; +import '../wire_colors.dart'; class PropertiesPanel extends StatefulWidget { final FlowEditorController controller; @@ -673,21 +674,7 @@ class _EdgeInfoView extends StatelessWidget { return null; } - Color _typeColor(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); - default: - return theme.colorScheme.outline; - } - } + Color _typeColor(String type, ThemeData theme) => wireColor(type, theme); void _disconnect(FlowEdge edge) { final graph = controller.graph; diff --git a/lib/src/wire_colors.dart b/lib/src/wire_colors.dart new file mode 100644 index 0000000..8724cbf --- /dev/null +++ b/lib/src/wire_colors.dart @@ -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 F∆I 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 F∆I 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 kKnownTypes = { + 'text', + 'json', + 'bytes', + 'file', + 'number', + 'integer', +}; diff --git a/pubspec.yaml b/pubspec.yaml index b9ef833..ca3613a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: fai_studio_flow_editor description: Swappable inline YAML editor for F∆I Studio flows. -version: 0.14.0 +version: 0.15.0 publish_to: 'none' repository: https://git.flemming.ai/fai/studio-flow-editor