fix(editor): solid-filled connected port + larger dot + tooltip fallback

Three issues from Stefan's screenshot:

1. Connected port dots rendered as a ring with a small
   surface-coloured pin in the centre — read as a hollow
   bullseye target rather than 'this port is wired'.
   Stefan asked for a plain filled circle: removed the
   inner pin so connected = solid disc, disconnected =
   ring with surface fill. One unambiguous visual switch.

2. Port dots bumped from 12 px to 14 px diameter. At 50 %
   zoom (and under the wider cards we now draw) the 12 px
   outline was sinking into the canvas background.

3. Tooltips now appear on every visible port label, not
   only on those whose ModuleSpec carries a description.
   Fallback chain:
     - manifest description in active locale
     - manifest description in English (peer)
     - 'fieldname · type' when the field is declared but
       the description is missing
     - bare 'fieldname' for implicit (YAML-only) fields
   Operators always see something on hover, useful when
   the hub binary is older than the per-field ModuleInfo
   RPC and the editor is running in implicit-only mode.

Bumps fai_studio_flow_editor to 0.10.3.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-02 00:56:14 +02:00
parent 3a6e92fe09
commit a1d25fd48a
3 changed files with 57 additions and 54 deletions

View file

@ -746,16 +746,34 @@ class _FlowCanvasState extends State<FlowCanvas>
final spec = _specForStep(step);
final inputLabels = _inputLabelsForStep(step);
final outputLabels = _outputLabelsForStep(step);
// Tooltips: every visible label gets one. When the
// manifest carries a locale description, use it. Else
// fall back to `name (type)` for declared fields whose
// description is missing, or to the bare field name for
// implicit (YAML-only) fields. Operators always see
// something on hover useful at least to confirm the
// exact field they're pointing at.
final tooltips = <String, String>{};
final loc = widget.locale == FlowEditorLocale.de ? 'de' : 'en';
final declaredFields = <String, ModuleField>{};
if (spec != null) {
final loc = widget.locale == FlowEditorLocale.de ? 'de' : 'en';
for (final f in spec.inputs) {
final d = f.descriptionFor(loc);
if (d != null) tooltips[f.name] = d;
for (final f in [...spec.inputs, ...spec.outputs]) {
declaredFields[f.name] = f;
}
for (final f in spec.outputs) {
}
for (final label in [...inputLabels, ...outputLabels]) {
final f = declaredFields[label];
if (f != null) {
final d = f.descriptionFor(loc);
if (d != null) tooltips[f.name] = d;
if (d != null && d.isNotEmpty) {
tooltips[label] = d;
} else if (f.type.isNotEmpty) {
tooltips[label] = '$label · ${f.type}';
} else {
tooltips[label] = label;
}
} else {
tooltips[label] = label;
}
}
final cardHeight = NodeGeometry.heightFor(
@ -1714,50 +1732,32 @@ class _FlowCanvasState extends State<FlowCanvas>
onLongPressStart: onContextMenu == null
? null
: (details) => onContextMenu(details.globalPosition),
child: Stack(
alignment: Alignment.center,
children: [
// Outer ring defines the port's footprint.
// Filled when connected (so the line "docks"
// into a visible target), surface-filled when
// dangling (a clear empty socket). Drop-target
// halo grows + glows.
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: filled ? accent : theme.colorScheme.surface,
border: Border.all(
color: accent,
width: isClosest ? 2.5 : (isHovered ? 2.2 : 1.8),
),
boxShadow: (isClosest || isHovered)
? [
BoxShadow(
color: accent.withValues(
alpha: isClosest ? 0.55 : 0.35,
),
blurRadius: isClosest ? 10 : 6,
),
]
: null,
),
child: Container(
// Connected = solid filled circle in accent.
// Dangling = clear ring with surface fill so the
// empty socket is obvious. Drop-target halo
// glows in addition. No inner pin Stefan
// explicitly wants the connected variant to read
// as one continuous filled disc, not a ring with
// a bullseye in it.
decoration: BoxDecoration(
shape: BoxShape.circle,
color: filled ? accent : theme.colorScheme.surface,
border: Border.all(
color: accent,
width: isClosest ? 2.5 : (isHovered ? 2.2 : 1.8),
),
// Inner pin tiny surface-coloured dot at the
// centre when connected, giving the port the
// "socket with a pin in it" look that reads as
// an active electrical connector rather than a
// free-floating indicator. Skipped on empty
// ports so the hollow ring is unambiguous.
if (connected)
Container(
width: 4,
height: 4,
decoration: BoxDecoration(
color: theme.colorScheme.surface,
shape: BoxShape.circle,
),
),
],
boxShadow: (isClosest || isHovered)
? [
BoxShadow(
color: accent.withValues(
alpha: isClosest ? 0.55 : 0.35,
),
blurRadius: isClosest ? 10 : 6,
),
]
: null,
),
),
),
),

View file

@ -83,10 +83,13 @@ class NodeGeometry {
/// 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;
/// Diameter of the canvas-side port dot. Sized so the
/// connected-vs-outlined state reads clearly at any zoom:
/// a 12 px outline tends to disappear into the background
/// at 50 % zoom; 14 px keeps the ring visible and gives
/// the filled (connected) variant enough mass to stand out
/// against the card shadow.
static const double portDotSize = 14;
/// Total card height for a node carrying [inputCount] inputs
/// on the left + [outputCount] outputs on the right. The