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:
parent
296e5bfd01
commit
fb8892687d
6 changed files with 761 additions and 81 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue