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:
parent
73736769a0
commit
885d2db4e1
8 changed files with 400 additions and 52 deletions
242
lib/src/flow_yaml_controller.dart
Normal file
242
lib/src/flow_yaml_controller.dart
Normal file
|
|
@ -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<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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue