feat(editor): three-tab WYSIWYG editor — graph / text / run
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>
This commit is contained in:
parent
dbd9a0004f
commit
870cbc29f7
12 changed files with 2769 additions and 408 deletions
264
lib/src/widgets/flow_node.dart
Normal file
264
lib/src/widgets/flow_node.dart
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
// 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue