feat(editor): port geometry fix + type colours + endpoint editor + unsaved badge

Four operator-visible improvements in one cut.

Port geometry (the "connection dots don't line up with the
labels" bug):
 - Header now has a fixed two-line structure (title + 16-px
   subtitle slot) regardless of node kind, so the body's
   port-row Y-offsets are identical across step nodes and
   endpoint nodes.
 - NodeGeometry constants rewritten so the inline 8-px dot
   inside each port row and the 16-px canvas-side dot both
   compute to the same Y. Port rows now anchor where the
   eye expects them.

Port type colours:
 - Inputs endpoint labels carry `name: type` (e.g.
   `doc: bytes`), so the FlowNode body parses the type and
   tints the inline dot per type: text → primary,
   bytes/file → tertiary, json → secondary, number → amber,
   unknown → muted. Step input ports stay muted until a
   future commit can read module manifests for type info.

Endpoint editor:
 - Inputs and outputs nodes are now selectable. Selecting
   opens a dedicated editor in the properties panel:
   add / rename / retype / remove entries graphically
   instead of forcing the operator into the text tab.
   Inputs editor offers a Type dropdown
   (text / bytes / json / file / number);
   outputs editor exposes name + expression
   (e.g. `$step.field`).

Unsaved badge:
 - The toolbar's tiny 8-px dirty dot was easy to miss.
   Replace it with a coloured "unsaved" / "ungespeichert"
   chip + colour the file name itself in the primary accent
   when dirty. Operators see at a glance that a flow has
   unsaved changes, which matters because the Discard
   confirmation prompt won't fire if they don't realise
   they made changes.

Version 0.3.0 -> 0.4.0 — first new editor feature surface
since 0.3.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-01 16:31:28 +02:00
parent 296e5bfd01
commit fb8892687d
6 changed files with 761 additions and 81 deletions

View file

@ -19,36 +19,55 @@ 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.
/// 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;
static const double headerHeight = 36;
// 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 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;
static const double bodyTopPad = 6;
static const double bodyBottomPad = 10;
static const double portDotSize = 12;
/// 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.
/// Total card height for a node with [portCount] inputs.
static double heightFor(int portCount) {
final base = headerHeight + bottomPadding;
final ports = portCount * portRowHeight;
return base + ports + 8;
return headerHeight +
bodyTopPad +
portCount * portRowHeight +
bodyBottomPad;
}
/// 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.
/// 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 + 6 + index * portRowHeight + portRowHeight / 2;
return headerHeight +
bodyTopPad +
index * portRowHeight +
portRowHeight / 2;
}
/// Y offset of the single output port. Vertically centred
/// in the header band so the edge entry feels balanced.
/// 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;
}
@ -136,6 +155,10 @@ class FlowNode extends StatelessWidget {
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(
@ -145,77 +168,105 @@ class FlowNode extends StatelessWidget {
topRight: Radius.circular(FaiRadius.md),
),
),
child: Row(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
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,
),
// 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 (status != FlowNodeStatus.idle) _statusDot(theme),
],
),
),
if (status != FlowNodeStatus.idle) _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();
}
return Padding(
padding: const EdgeInsets.fromLTRB(
FaiSpace.sm,
4,
FaiSpace.sm,
FaiSpace.sm,
padding: const EdgeInsets.only(
top: NodeGeometry.bodyTopPad,
bottom: NodeGeometry.bodyBottomPad,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
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,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.sm),
child: Row(
children: [
// Inline port dot coloured the same as
// the canvas-side dot so the operator can
// see exactly which row a connection lands
// on. Same vertical centre as the canvas-
// side dot because both are vertically
// centred in this 22-px row.
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: _portColorFor(label, theme),
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,
),
),
),
],
),
),
),
],
@ -223,6 +274,38 @@ class FlowNode extends StatelessWidget {
);
}
/// Colour for the inline port dot. For now we only have
/// type info for the inputs endpoint (labels carry
/// `name: type` like `doc: bytes`); step inputs don't
/// declare types in the YAML and we'd need module
/// manifests to colour them properly. Until that lands,
/// step input ports fall back to neutral and only the
/// inputs sidebar surfaces type colours.
Color _portColorFor(String label, ThemeData theme) {
final colon = label.indexOf(':');
if (colon < 0) return theme.colorScheme.onSurfaceVariant;
final type = label.substring(colon + 1).trim();
return _typeAccent(type, theme);
}
Color _typeAccent(String type, ThemeData theme) {
final cs = theme.colorScheme;
switch (type) {
case 'text':
return cs.primary;
case 'bytes':
case 'file':
return cs.tertiary;
case 'json':
return cs.secondary;
case 'number':
case 'integer':
return Colors.amber.shade400;
default:
return cs.onSurfaceVariant;
}
}
Widget _statusDot(ThemeData theme) {
final color = switch (status) {
FlowNodeStatus.running => theme.colorScheme.primary,