// FlowYamlCodeController — CodeController subclass that adds // F∆I-specific colouring on top of the stock YAML highlighter // and routes analyzer-issue hovers + reanalyze cycles for the // editor host. // // 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 // recolour wire-typed values, lay wavy underlines on broken // lines, and attach pointer-hover handlers so the editor host // can pop a tooltip with the message + quick fixes. // // 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 'dart:async'; import 'package:flutter/gestures.dart'; 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 'quick_fix.dart'; import 'wire_colors.dart'; class FlowYamlCodeController extends CodeController { FlowYamlCodeController({ List Function()? availableCapabilities, List Function()? storeCapabilities, }) : super( text: '', language: yaml, analyzer: FlowAnalyzer( availableCapabilities: availableCapabilities ?? (() => const []), storeCapabilities: storeCapabilities, ), ); /// Set both capability providers in one go. Re-creating the /// analyzer is the simplest way to flip both closures without /// risking a stale storeCapabilities reference inside an old /// FlowAnalyzer instance. void setCapabilityProviders({ required List Function() available, List Function()? store, }) { analyzer = FlowAnalyzer( availableCapabilities: available, storeCapabilities: store, ); } /// Worst-severity per step / pseudo-node from the analyzer's /// last pass. Graph canvas reads this to decide which step /// nodes pulse red / amber. Returns an empty map when the /// active analyzer isn't a [FlowAnalyzer]. Map get stepSeverity { final a = analyzer; return a is FlowAnalyzer ? a.stepSeverity : const {}; } /// Published when the pointer enters a wavy-underlined range. /// The editor host (FlowEditorPage) listens and renders an /// overlay tooltip near the cursor. Reset to `null` once the /// pointer leaves both the range AND the tooltip. final ValueNotifier hoverRequest = ValueNotifier(null); Timer? _hideTimer; /// Cancel the pending hover-dismiss timer. The host calls /// this when the pointer enters the floating tooltip card so /// the operator can click an action button without the tip /// vanishing under them. void cancelHoverDismiss() { _hideTimer?.cancel(); _hideTimer = null; } /// Schedule the hover tooltip to clear after a short grace /// period. Lets the operator slide from the underline into /// the tooltip without the latter snapping away. void scheduleHoverDismiss() { _hideTimer?.cancel(); _hideTimer = Timer(const Duration(milliseconds: 220), () { hoverRequest.value = null; _hideTimer = null; }); } /// Clear the hover tooltip immediately — used when the editor /// is being disposed or the host wants to dismiss the tip /// (e.g. after applying a quick fix). void clearHover() { _hideTimer?.cancel(); _hideTimer = null; hoverRequest.value = null; } /// 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); } /// Run the analyzer against the current buffer and publish /// the new result. The host calls this after a quick fix that /// changes external state (e.g. installing a module) so the /// strip + underlines update without waiting for the next /// keystroke. Future reanalyze() async { final result = await analyzer.analyze(code); analysisResult = result; notifyListeners(); } /// Read-only access to the quick fixes computed for an issue. /// Returns an empty list when the analyzer is the default /// `DefaultLocalAnalyzer` (i.e. no language host hooked up). List fixesFor(Issue issue) { final a = analyzer; return a is FlowAnalyzer ? a.fixesFor(issue) : const []; } @override void dispose() { _hideTimer?.cancel(); hoverRequest.dispose(); super.dispose(); } // Matches an indented `key: value` line where the value is // one of our known type tokens. Covers both shapes the F∆I // YAML uses (`pdf: bytes` shorthand and the explicit // `type: bytes` row). static final RegExp _typeAssignment = RegExp( r'''^[ \t]+[A-Za-z_][\w-]*\s*:\s*(?:['"])?([A-Za-z_][\w-]*)(?:['"])?\s*(?:#.*)?$''', multiLine: true, ); @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; var endOffset = line.textRange.end; if (text.endsWith('\n')) endOffset -= 1; var startOffset = line.textRange.start; 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, ), issue: issue, ), ); } return out; } 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; } 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(_buildOverrideSpan(localText, before, overlap, leaf, ov)); 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); } TextSpan _buildOverrideSpan( String localText, int before, int overlap, _Leaf leaf, _Override ov, ) { final mergedStyle = (leaf.style ?? const TextStyle()).merge(ov.style); final issue = ov.issue; if (issue == null) { return TextSpan( text: localText.substring(before, overlap), style: mergedStyle, ); } // Capture the issue + analyzer-supplied fixes so hover // handlers can publish a self-contained request without // round-tripping through the host on every pointer event. final fixes = fixesFor(issue); final severity = switch (issue.type) { IssueType.error => IssueHoverSeverity.error, IssueType.warning => IssueHoverSeverity.warning, IssueType.info => IssueHoverSeverity.info, }; return TextSpan( text: localText.substring(before, overlap), style: mergedStyle, mouseCursor: SystemMouseCursors.help, onEnter: (PointerEnterEvent e) { _hideTimer?.cancel(); _hideTimer = null; hoverRequest.value = IssueHoverRequest( line: issue.line, message: issue.message, severity: severity, fixes: fixes, globalPosition: e.position, ); }, onExit: (PointerExitEvent _) { scheduleHoverDismiss(); }, ); } 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; final Issue? issue; const _Override(this.start, this.end, this.style, {this.issue}); } class _Leaf { final String text; final TextStyle? style; const _Leaf(this.text, this.style); }