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
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
98
lib/src/flow_analyzer.dart
Normal file
98
lib/src/flow_analyzer.dart
Normal 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);
|
||||
}
|
||||
|
|
@ -100,6 +100,9 @@ class _FlowEditorPageState extends State<FlowEditorPage>
|
|||
super.initState();
|
||||
_l = FlowEditorStrings(widget.locale);
|
||||
_controller = FlowEditorController();
|
||||
_controller.codeController.setAvailableCapabilities(
|
||||
() => widget.availableCapabilities,
|
||||
);
|
||||
_controller.addListener(_onCtrlChanged);
|
||||
_tabs = TabController(length: 3, vsync: this);
|
||||
_files = _listFiles();
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
|
|
@ -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<FlowCanvas>
|
|||
/// 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<void> _disconnectInputPort(
|
||||
String stepId,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
49
lib/src/wire_colors.dart
Normal file
49
lib/src/wire_colors.dart
Normal 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 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<String> kKnownTypes = {
|
||||
'text',
|
||||
'json',
|
||||
'bytes',
|
||||
'file',
|
||||
'number',
|
||||
'integer',
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue