Full rewrite of the editor surface, layered on top of the
FlowGraph foundation. One in-memory flow drives three tabs
that the operator can flip between freely:
- Graph: a drag-and-drop canvas. Nodes are step cards with
port dots on their left (one per `with:` field) and a
combined output port on the right. Pinned inputs and
outputs pseudo-nodes sit at the left and right edges so
every flow has a visually obvious source and sink. Pan +
zoom via InteractiveViewer; drag a node by its body to
reposition it (positions persisted to a sidecar JSON file
under ~/.fai/data/flows/.layout/<name>.json — kept OUT of
the YAML so `fai run` stays byte-stable).
- Text: the existing YAML CodeField with expands:true so
line 1 anchors at the top edge. YAML-aware syntax
highlighting picks up the theme's primary / secondary /
tertiary palette for keys / strings / numbers.
- Run: an inputs form (text fields + file-pick), a Start
button that calls the host's FlowRunDriver, a live step
list driven by the driver's event stream (matches the
`fai run` CLI rendering — ◻ pending, · running, ✔ done +
duration, ✗ failed, ⏸ awaiting approval), and the typed
outputs once the run resolves.
Source of truth = the YAML text. Graph edits emit fresh YAML
into the shared CodeController; text edits re-parse the
graph on a 350 ms debounce. Layout sidecar persists drag
positions only.
New public API (lib/fai_studio_flow_editor.dart):
FlowEditorPage(
initialFlowName: ...,
locale: ...,
runDriver: FlowRunDriver?, // NEW — host bridge
availableCapabilities: List<String>, // NEW — for the
// capability picker
// dialog when adding
// a step
)
The host (Studio) implements FlowRunDriver to bridge the
hub's gRPC SDK into the editor's event vocabulary. The
StepStarted/Completed/Failed/AwaitingApproval events are
shared verbatim with the CLI's run_progress renderer so
both surfaces speak the same visual language.
Files in this commit:
- lib/src/editor_controller.dart — shared state +
debounced reparse loop
- lib/src/run_driver.dart — host bridge
interface + event types
- lib/src/widgets/flow_canvas.dart — pan / zoom / drag /
port-to-port connection drawing
- lib/src/widgets/flow_node.dart — node card primitive
(module / approval / inputs / outputs variants)
- lib/src/widgets/edge_painter.dart — single CustomPainter
for every edge + draft drag line, cubic bezier with
arrow-head caps
- lib/src/widgets/properties_panel.dart — right-side editor
when a step is selected (rename id, change capability, add
/ remove / rename with-fields, delete step)
- lib/src/widgets/capability_picker.dart — searchable list
dialog used by Add-step
- lib/src/widgets/run_tab.dart — inputs form +
live step progress + outputs renderer
- lib/src/flow_editor_page.dart — host scaffolding,
toolbar, file list, three-tab body, keyboard shortcuts
- lib/src/l10n.dart — EN + DE strings for
every new label
- lib/fai_studio_flow_editor.dart — exports the new
public types (FlowRunDriver, FlowRunEvent variants,
FlowOutputValue variants)
flutter analyze: 0 issues. flutter test: 7/7 green.
Signed-off-by: flemming-it <sf@flemming.it>
264 lines
8.3 KiB
Dart
264 lines
8.3 KiB
Dart
// FlowNode — the visual representation of one step on the
|
|
// canvas. Three visual variants:
|
|
//
|
|
// - module step: rounded card, gear icon, primary border
|
|
// when selected
|
|
// - approval step: tertiary tint, hand-up icon, marks where
|
|
// the flow waits for a human
|
|
// - inputs / outputs sidebar nodes: tall, pinned to edges
|
|
//
|
|
// The card exposes named ports on its left (one per `with:`
|
|
// key) and a single combined port on its right that all
|
|
// downstream edges leave from. Ports are small filled circles
|
|
// just outside the card border so edges can visually anchor
|
|
// to them.
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../model/flow_graph.dart';
|
|
import '../tokens.dart';
|
|
|
|
/// Geometry constants the canvas + edge painter rely on to
|
|
/// place ports without measuring widgets at runtime. Changing
|
|
/// any of these requires reviewing edge_painter.dart.
|
|
class NodeGeometry {
|
|
static const double width = 220;
|
|
static const double headerHeight = 36;
|
|
static const double portRowHeight = 22;
|
|
static const double bottomPadding = 12;
|
|
static const double portRadius = 6;
|
|
// Offset from the card's outer edge for the port's center.
|
|
// Half-out so the dot visually "attaches" to the side.
|
|
static const double portInset = 0;
|
|
|
|
/// Total height for a step node given its with-field count.
|
|
/// Approval and inputs/outputs variants use the same maths
|
|
/// so the canvas can place every node identically.
|
|
static double heightFor(int portCount) {
|
|
final base = headerHeight + bottomPadding;
|
|
final ports = portCount * portRowHeight;
|
|
return base + ports + 8;
|
|
}
|
|
|
|
/// Y offset of an input port's centre, measured from the
|
|
/// top of the card. The first input port sits below the
|
|
/// header with a small gap.
|
|
static double inputPortY(int index) {
|
|
return headerHeight + 6 + index * portRowHeight + portRowHeight / 2;
|
|
}
|
|
|
|
/// Y offset of the single output port. Vertically centred
|
|
/// in the header band so the edge entry feels balanced.
|
|
static double outputPortY() {
|
|
return headerHeight / 2;
|
|
}
|
|
}
|
|
|
|
enum NodeVisualKind { module, approval, inputs, outputs }
|
|
|
|
class FlowNode extends StatelessWidget {
|
|
final String id;
|
|
final String title;
|
|
final String? subtitle;
|
|
final List<String> inputPortLabels;
|
|
final NodeVisualKind kind;
|
|
final bool selected;
|
|
final VoidCallback? onTap;
|
|
final void Function(Offset delta)? onDrag;
|
|
final VoidCallback? onDragEnd;
|
|
|
|
/// Live status from the most recent run, when this node is
|
|
/// a step. Coloured dot in the header so the operator can
|
|
/// glance at the canvas and see what's running.
|
|
final FlowNodeStatus status;
|
|
|
|
const FlowNode({
|
|
super.key,
|
|
required this.id,
|
|
required this.title,
|
|
this.subtitle,
|
|
this.inputPortLabels = const [],
|
|
this.kind = NodeVisualKind.module,
|
|
this.selected = false,
|
|
this.status = FlowNodeStatus.idle,
|
|
this.onTap,
|
|
this.onDrag,
|
|
this.onDragEnd,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final accent = _accent(theme.colorScheme);
|
|
final height = NodeGeometry.heightFor(inputPortLabels.length);
|
|
return SizedBox(
|
|
width: NodeGeometry.width,
|
|
height: height,
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: onTap,
|
|
onPanUpdate: onDrag == null ? null : (d) => onDrag!(d.delta),
|
|
onPanEnd: onDragEnd == null ? null : (_) => onDragEnd!(),
|
|
child: Material(
|
|
color: theme.colorScheme.surfaceContainerHigh,
|
|
elevation: selected ? 6 : 2,
|
|
shadowColor: selected ? accent.withValues(alpha: 0.35) : null,
|
|
borderRadius: BorderRadius.circular(FaiRadius.md),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(FaiRadius.md),
|
|
border: Border.all(
|
|
color: selected ? accent : theme.dividerColor,
|
|
width: selected ? 2 : 1,
|
|
),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_header(theme, accent),
|
|
Expanded(child: _body(theme)),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _header(ThemeData theme, Color accent) {
|
|
return Container(
|
|
height: NodeGeometry.headerHeight,
|
|
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.sm),
|
|
decoration: BoxDecoration(
|
|
color: accent.withValues(alpha: 0.16),
|
|
borderRadius: const BorderRadius.only(
|
|
topLeft: Radius.circular(FaiRadius.md),
|
|
topRight: Radius.circular(FaiRadius.md),
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(_iconFor(kind), size: 16, color: accent),
|
|
const SizedBox(width: FaiSpace.xs),
|
|
Expanded(
|
|
child: Text(
|
|
title,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: theme.textTheme.titleSmall?.copyWith(
|
|
fontFamily: 'monospace',
|
|
fontWeight: FontWeight.w600,
|
|
color: accent,
|
|
),
|
|
),
|
|
),
|
|
if (status != FlowNodeStatus.idle) _statusDot(theme),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _body(ThemeData theme) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
FaiSpace.sm,
|
|
4,
|
|
FaiSpace.sm,
|
|
FaiSpace.sm,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (subtitle != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 4),
|
|
child: Text(
|
|
subtitle!,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
fontFamily: 'monospace',
|
|
),
|
|
),
|
|
),
|
|
for (final label in inputPortLabels)
|
|
SizedBox(
|
|
height: NodeGeometry.portRowHeight,
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 6,
|
|
height: 6,
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
const SizedBox(width: FaiSpace.xs),
|
|
Flexible(
|
|
child: Text(
|
|
label,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: theme.colorScheme.onSurface,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _statusDot(ThemeData theme) {
|
|
final color = switch (status) {
|
|
FlowNodeStatus.running => theme.colorScheme.primary,
|
|
FlowNodeStatus.done => Colors.green.shade400,
|
|
FlowNodeStatus.failed => theme.colorScheme.error,
|
|
FlowNodeStatus.awaiting => theme.colorScheme.tertiary,
|
|
FlowNodeStatus.idle => Colors.transparent,
|
|
};
|
|
return Container(
|
|
width: 8,
|
|
height: 8,
|
|
decoration: BoxDecoration(
|
|
color: color,
|
|
shape: BoxShape.circle,
|
|
boxShadow: status == FlowNodeStatus.running
|
|
? [BoxShadow(color: color.withValues(alpha: 0.6), blurRadius: 6)]
|
|
: null,
|
|
),
|
|
);
|
|
}
|
|
|
|
IconData _iconFor(NodeVisualKind k) {
|
|
return switch (k) {
|
|
NodeVisualKind.module => Icons.widgets_outlined,
|
|
NodeVisualKind.approval => Icons.pan_tool_outlined,
|
|
NodeVisualKind.inputs => Icons.input,
|
|
NodeVisualKind.outputs => Icons.output,
|
|
};
|
|
}
|
|
|
|
Color _accent(ColorScheme cs) {
|
|
return switch (kind) {
|
|
NodeVisualKind.module => cs.primary,
|
|
NodeVisualKind.approval => cs.tertiary,
|
|
NodeVisualKind.inputs => cs.secondary,
|
|
NodeVisualKind.outputs => cs.secondary,
|
|
};
|
|
}
|
|
}
|
|
|
|
enum FlowNodeStatus { idle, running, done, failed, awaiting }
|
|
|
|
/// Render-time helper: figure out which visual variant to use
|
|
/// for a parsed step.
|
|
NodeVisualKind kindForStep(FlowStep step) {
|
|
if (step.isApproval) return NodeVisualKind.approval;
|
|
return NodeVisualKind.module;
|
|
}
|