chain-studio-flow-editor/lib/src/widgets/flow_node.dart
flemming-it 5ff7a1c118 feat(editor): edge hover + edge menu + pattern picker + zoom indicator
Closes four operator complaints from the 0.5.3 review.

1. Edges now react to hover.

   Added an interaction layer between the edge painter and
   the endpoint nodes. A MouseRegion tracks the cursor in
   canvas coords; every move runs a spatial hit-test
   against each edge's bezier sampled at 24 points. The
   closest edge within 8 px highlights in the primary
   accent so the operator sees what they're aiming at.
   Cursor leaves canvas → highlight clears.

2. Right-click / long-press on edges opens a Disconnect
   menu.

   Same interaction layer carries `onSecondaryTapDown` and
   `onLongPressStart` handlers. Both run the same hit-test
   and pop the menu at the cursor. Disconnect clears the
   target's expression (step.with[field] = '' or
   outputs[name] = ''), edge disappears, target port flips
   outlined.

3. Long-press is now available alongside right-click
   EVERYWHERE the context menu lives — step nodes, port
   dots, edges. Trackpad-only users whose "two-finger
   click" isn't bound to secondary get the same affordances
   as mouse users.

4. Background pattern is now operator-selectable: cycles
   through dots → grid → blank via a small icon button on
   the bottom-right canvas controls. The button's icon and
   tooltip reflect the next state. Picked dots as the
   default because they're least visually noisy at scale.

5. Zoom indicator: a mono "100%" readout next to Fit-to-
   screen shows the current InteractiveViewer scale,
   updating live as the operator pinches / scrolls. Tap
   to snap back to 100 % without losing pan position.

The bottom-right control cluster is now:
  [pattern] [reset layout] [fit to screen] [zoom%]

Version 0.5.3 -> 0.6.0 (minor bump — new interaction
surfaces + visible toolbar additions).

Signed-off-by: flemming-it <sf@flemming.it>
2026-06-01 17:36:24 +02:00

415 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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. Every
/// number here must match the actual widget layout produced
/// by FlowNode.build — when these drift, the connection
/// dots stop aligning with the inline port-labels and edges
/// look like they're attached to nothing in particular.
///
/// Layout reads top-to-bottom:
/// - header band (titleHeight + subtitleHeight + chrome)
/// - bodyTopPad
/// - N × portRowHeight (each a row that vertically centres
/// its content)
/// - bodyBottomPad
class NodeGeometry {
static const double width = 220;
// Header band is constant whether the node has a subtitle
// or not. Endpoint nodes (inputs / outputs) render an empty
// subtitle slot — that keeps port-row Y-offsets identical
// across every node kind, which is what the port-dot
// positioning relies on.
static const double titleLineHeight = 22;
static const double subtitleLineHeight = 16;
static const double headerHeight = titleLineHeight + subtitleLineHeight + 10;
static const double portRowHeight = 22;
static const double bodyTopPad = 6;
static const double bodyBottomPad = 10;
/// Reserved space on the port-bearing edge inside the body
/// — leaves room for the canvas-side port dot to land
/// without overlapping the label text.
static const double portGutter = 18;
/// Diameter of the canvas-side port dot — sized to the
/// body's labelSmall row (~11 px text + 11 px breathing
/// room) so the dot reads as the port without crowding.
static const double portDotSize = 12;
/// Total card height for a node with [portCount] inputs.
static double heightFor(int portCount) {
return headerHeight +
bodyTopPad +
portCount * portRowHeight +
bodyBottomPad;
}
/// Y offset (from the card's top edge) where the input
/// port at row [index] is vertically centred. Match this
/// exactly with the inline 6-px dot inside FlowNode._body.
static double inputPortY(int index) {
return headerHeight +
bodyTopPad +
index * portRowHeight +
portRowHeight / 2;
}
/// Y offset of the single output port. Vertically centred
/// in the header so the edge entry feels balanced and so
/// "the step's primary output" reads as a peer of the
/// title rather than buried under the with-fields.
static double outputPortY() {
return headerHeight / 2;
}
}
enum NodeVisualKind { module, approval, inputs, outputs }
/// Which side of the card the labels' associated ports sit on.
/// Drives label text-alignment and informs the canvas where to
/// drop port-dot widgets.
///
/// - `left`: port dots hang off the left edge, labels
/// left-aligned (used for step inputs, outputs
/// endpoint inputs).
/// - `right`: port dots hang off the right edge, labels
/// right-aligned (used for inputs endpoint —
/// its body labels represent OUTPUTS of the
/// node since data flows from the inputs
/// endpoint OUT to downstream steps).
enum NodePortSide { left, right }
class FlowNode extends StatelessWidget {
final String id;
final String title;
final String? subtitle;
final List<String> inputPortLabels;
final NodeVisualKind kind;
final NodePortSide portSide;
final bool selected;
final VoidCallback? onTap;
final void Function(Offset delta)? onDrag;
final VoidCallback? onDragEnd;
/// Inputs that COULD be set but are intentionally optional
/// — drawn slightly muted so the operator can see at a
/// glance which slots are "must wire" vs "may wire". Until
/// we have module manifests, every step-input defaults to
/// "must wire" and this is empty.
final Set<String> optionalLabels;
/// How many of [inputPortLabels] are currently wired to an
/// upstream source. When equal to the total count, the
/// header paints a "complete" check mark; otherwise it
/// renders the ratio (e.g. "3/5") so the operator sees
/// at a glance what's still to wire. Set to null on
/// endpoint nodes where the ratio doesn't apply.
final int? wiredCount;
/// Right-click handler on the node body. Canvas wires this
/// to a popup menu at the cursor position (Duplicate /
/// Delete / Disconnect inputs etc.).
final void Function(Offset globalPos)? onContextMenu;
/// 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.portSide = NodePortSide.left,
this.optionalLabels = const {},
this.wiredCount,
this.selected = false,
this.status = FlowNodeStatus.idle,
this.onTap,
this.onDrag,
this.onDragEnd,
this.onContextMenu,
});
@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!(),
onSecondaryTapDown: onContextMenu == null
? null
: (details) => onContextMenu!(details.globalPosition),
// Long-press is the trackpad fallback for the context
// menu — macOS trackpad users whose "two-finger click"
// isn't mapped to secondary still get the same menu.
onLongPressStart: onContextMenu == null
? null
: (details) => onContextMenu!(details.globalPosition),
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(
// Fixed header height (regardless of whether subtitle
// is present) — so the body's port-row Y offsets are
// identical across every node kind. Endpoint nodes
// simply leave the subtitle line blank.
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: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title line — icon + step id + optional status dot.
SizedBox(
height: NodeGeometry.titleLineHeight,
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 (wiredCount != null && inputPortLabels.isNotEmpty)
_wiredBadge(theme, accent),
if (status != FlowNodeStatus.idle) ...[
const SizedBox(width: 4),
_statusDot(theme),
],
],
),
),
// Subtitle line — always reserved space, even for
// endpoint nodes that have nothing to put here.
SizedBox(
height: NodeGeometry.subtitleLineHeight,
child: subtitle == null
? const SizedBox.shrink()
: Padding(
// Indent to align with title text after the icon.
padding: const EdgeInsets.only(left: 20),
child: Text(
subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontFamily: 'monospace',
fontSize: 10,
),
),
),
),
],
),
);
}
Widget _body(ThemeData theme) {
if (inputPortLabels.isEmpty) {
return const SizedBox.shrink();
}
final align = portSide == NodePortSide.right
? TextAlign.right
: TextAlign.left;
final crossAlign = portSide == NodePortSide.right
? CrossAxisAlignment.end
: CrossAxisAlignment.start;
// Leave a clear gutter on the port side so the canvas
// dot has somewhere to land. Other side gets normal
// padding.
final padding = portSide == NodePortSide.right
? const EdgeInsets.fromLTRB(
FaiSpace.sm,
NodeGeometry.bodyTopPad,
NodeGeometry.portGutter,
NodeGeometry.bodyBottomPad,
)
: const EdgeInsets.fromLTRB(
NodeGeometry.portGutter,
NodeGeometry.bodyTopPad,
FaiSpace.sm,
NodeGeometry.bodyBottomPad,
);
return Padding(
padding: padding,
child: Column(
crossAxisAlignment: crossAlign,
children: [
for (final label in inputPortLabels)
SizedBox(
height: NodeGeometry.portRowHeight,
child: Align(
alignment: portSide == NodePortSide.right
? Alignment.centerRight
: Alignment.centerLeft,
child: Text(
label,
textAlign: align,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
color: optionalLabels.contains(label)
? theme.colorScheme.onSurface.withValues(alpha: 0.55)
: theme.colorScheme.onSurface,
fontStyle: optionalLabels.contains(label)
? FontStyle.italic
: FontStyle.normal,
),
),
),
),
],
),
);
}
Widget _wiredBadge(ThemeData theme, Color accent) {
final total = inputPortLabels.length;
final wired = wiredCount ?? 0;
final complete = wired >= total && total > 0;
if (complete) {
return Padding(
padding: const EdgeInsets.only(left: 4),
child: Icon(Icons.check_circle, size: 14, color: Colors.green.shade400),
);
}
return Padding(
padding: const EdgeInsets.only(left: 4),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: accent.withValues(alpha: 0.4)),
),
child: Text(
'$wired/$total',
style: theme.textTheme.labelSmall?.copyWith(
fontFamily: 'monospace',
fontSize: 9,
fontWeight: FontWeight.w600,
color: accent,
),
),
),
);
}
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;
}