fix(editor): draft line reaches cursor + ports react on hover

Two operator-visible fixes from the 0.5.2 review:

(1) Draft line stopped short of the cursor. EdgePainter
shortens every segment endpoint by portRadius to dock at
port-dot perimeters — but the DRAFT segment's `to` is the
cursor itself, not a port. Result: while dragging a
connection, the live line ended 6 px short of where the
operator's mouse actually was. Felt like the cable
wouldn't reach.

EdgeSegment gains `shortenFrom` / `shortenTo` flags
(default true). The draft segment passes `shortenTo:
false`, so the line tip tracks the cursor exactly. From-
side stays shortened because the FROM is still a real
port dot.

(2) Ports had no hover affordance — mouse-over was
invisible. Operators couldn't tell ahead of time that a
port was interactive.

Adds `_hoveredPort` state on the canvas, updated via the
existing MouseRegion's onEnter / onExit. Port dot now has
three focus levels:

  resting → 12 px, 1.8 px border, no glow
  hover   → 15 px, 2.2 px border, soft accent glow
  drop-target (drag is over it) → 18 px, 2.5 px border,
            stronger glow

The hover state pre-shadows the drop-target state —
operators see "yes, this port is grabbable / droppable"
before they commit to dragging. When the drag does start,
the drop-target halo is the same family of treatment,
just stronger, so the visual progression reads as one
continuous interaction.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-06-01 17:13:14 +02:00
parent 9afb51a4b5
commit 5c59f3a554
3 changed files with 59 additions and 19 deletions

View file

@ -22,12 +22,22 @@ class EdgeSegment {
final EdgeSide fromSide; final EdgeSide fromSide;
final EdgeSide toSide; final EdgeSide toSide;
final EdgeAccent accent; final EdgeAccent accent;
/// When false, the bezier endpoint is NOT shortened by
/// `portRadius` on the corresponding side. Used by the
/// draft-drag segment so the line actually reaches the
/// cursor instead of stopping a port-radius short of it
/// (the cursor is a mouse position, not a port socket).
final bool shortenFrom;
final bool shortenTo;
const EdgeSegment({ const EdgeSegment({
required this.from, required this.from,
required this.to, required this.to,
required this.fromSide, required this.fromSide,
required this.toSide, required this.toSide,
this.accent = EdgeAccent.normal, this.accent = EdgeAccent.normal,
this.shortenFrom = true,
this.shortenTo = true,
}); });
} }
@ -75,15 +85,14 @@ class EdgePainter extends CustomPainter {
// a left-side port has its outer perimeter to the left // a left-side port has its outer perimeter to the left
// of its centre, so we move the endpoint LEFT by // of its centre, so we move the endpoint LEFT by
// radius; right-side port goes the other way. // radius; right-side port goes the other way.
final shortenedFrom = Offset( final fromShift = seg.shortenFrom
seg.from.dx + ? (seg.fromSide == EdgeSide.right ? portRadius : -portRadius)
(seg.fromSide == EdgeSide.right ? portRadius : -portRadius), : 0.0;
seg.from.dy, final toShift = seg.shortenTo
); ? (seg.toSide == EdgeSide.right ? portRadius : -portRadius)
final shortenedTo = Offset( : 0.0;
seg.to.dx + (seg.toSide == EdgeSide.right ? portRadius : -portRadius), final shortenedFrom = Offset(seg.from.dx + fromShift, seg.from.dy);
seg.to.dy, final shortenedTo = Offset(seg.to.dx + toShift, seg.to.dy);
);
canvas.drawPath(_bezier(shortenedFrom, shortenedTo), paint); canvas.drawPath(_bezier(shortenedFrom, shortenedTo), paint);
} }
} }

View file

@ -74,6 +74,12 @@ class _FlowCanvasState extends State<FlowCanvas> {
// paints a draft edge from the source port to the cursor // paints a draft edge from the source port to the cursor
// and accepts a drop on any input port. // and accepts a drop on any input port.
_ConnectionDraft? _draft; _ConnectionDraft? _draft;
// Currently-hovered port id. Drives the hover halo so the
// operator gets a clear "this port is interactive"
// affordance before they commit to dragging or clicking.
// String key = same shape used by _connectedPorts so we
// can pass a single hovered-key into the port widget.
String? _hoveredPort;
@override @override
void initState() { void initState() {
@ -201,6 +207,13 @@ class _FlowCanvasState extends State<FlowCanvas> {
fromSide: EdgeSide.right, fromSide: EdgeSide.right,
toSide: EdgeSide.left, toSide: EdgeSide.left,
accent: EdgeAccent.draftDrag, accent: EdgeAccent.draftDrag,
// The draft target IS the
// cursor not a port socket.
// Don't shorten on that end or
// the line stops short of
// where the operator's mouse
// actually is.
shortenTo: false,
), ),
], ],
baseColor: theme.colorScheme.primary, baseColor: theme.colorScheme.primary,
@ -522,6 +535,7 @@ class _FlowCanvasState extends State<FlowCanvas> {
final p = _outputPortPosition(step.id, layout); final p = _outputPortPosition(step.id, layout);
final key = '${step.id}:__out__'; final key = '${step.id}:__out__';
yield _portDot( yield _portDot(
portKey: key,
center: p, center: p,
isSource: true, isSource: true,
connected: connectedPorts.contains(key), connected: connectedPorts.contains(key),
@ -542,6 +556,7 @@ class _FlowCanvasState extends State<FlowCanvas> {
final input = graph.inputs[fieldName]!; final input = graph.inputs[fieldName]!;
final key = 'inputs:$fieldName'; final key = 'inputs:$fieldName';
yield _portDot( yield _portDot(
portKey: key,
center: p, center: p,
isSource: true, isSource: true,
connected: connectedPorts.contains(key), connected: connectedPorts.contains(key),
@ -564,6 +579,7 @@ class _FlowCanvasState extends State<FlowCanvas> {
final value = step.with_[field]?.toString() ?? ''; final value = step.with_[field]?.toString() ?? '';
final wired = _isWiredExpression(value); final wired = _isWiredExpression(value);
yield _portDot( yield _portDot(
portKey: '${step.id}:$field',
center: p, center: p,
isSource: false, isSource: false,
connected: wired, connected: wired,
@ -582,6 +598,7 @@ class _FlowCanvasState extends State<FlowCanvas> {
final expr = graph.outputs[field] ?? ''; final expr = graph.outputs[field] ?? '';
final wired = _isWiredExpression(expr); final wired = _isWiredExpression(expr);
yield _portDot( yield _portDot(
portKey: 'outputs:$field',
center: p, center: p,
isSource: false, isSource: false,
connected: wired, connected: wired,
@ -717,6 +734,7 @@ class _FlowCanvasState extends State<FlowCanvas> {
} }
Widget _portDot({ Widget _portDot({
required String portKey,
required Offset center, required Offset center,
required bool isSource, required bool isSource,
required bool connected, required bool connected,
@ -728,11 +746,16 @@ class _FlowCanvasState extends State<FlowCanvas> {
final dragging = _draft != null; final dragging = _draft != null;
final isInputDuringDrag = dragging && !isSource; final isInputDuringDrag = dragging && !isSource;
final isClosest = isInputDuringDrag && _isClosestDropTarget(center); final isClosest = isInputDuringDrag && _isClosestDropTarget(center);
// Size logic: drop-target halo when a drag is hovering final isHovered = _hoveredPort == portKey;
// 18 px; otherwise the standard 12 px port that // Size scales with focus level:
// matches NodeGeometry.portDotSize. One single dot per // - drop-target halo (drag is over this port) 18 px
// port no more separate inline/canvas dots. // - hover (mouse-over without dragging) 15 px
final size = isClosest ? 18.0 : NodeGeometry.portDotSize; // - resting 12 px
// The hover bump is the new "this port is interactive"
// affordance Stefan asked for.
final size = isClosest
? 18.0
: (isHovered ? 15.0 : NodeGeometry.portDotSize);
// Fill rule: filled when this port participates in an // Fill rule: filled when this port participates in an
// edge, OR it's the closest drop target mid-drag. Plain // edge, OR it's the closest drop target mid-drag. Plain
// outlined circle when neither the operator sees at // outlined circle when neither the operator sees at
@ -745,6 +768,12 @@ class _FlowCanvasState extends State<FlowCanvas> {
height: size, height: size,
child: MouseRegion( child: MouseRegion(
cursor: isSource ? SystemMouseCursors.grab : SystemMouseCursors.cell, cursor: isSource ? SystemMouseCursors.grab : SystemMouseCursors.cell,
onEnter: (_) => setState(() => _hoveredPort = portKey),
onExit: (_) {
if (_hoveredPort == portKey) {
setState(() => _hoveredPort = null);
}
},
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onPanStart: !isSource onPanStart: !isSource
@ -785,13 +814,15 @@ class _FlowCanvasState extends State<FlowCanvas> {
color: filled ? accent : theme.colorScheme.surface, color: filled ? accent : theme.colorScheme.surface,
border: Border.all( border: Border.all(
color: accent, color: accent,
width: isClosest ? 2.5 : 1.8, width: isClosest ? 2.5 : (isHovered ? 2.2 : 1.8),
), ),
boxShadow: isClosest boxShadow: (isClosest || isHovered)
? [ ? [
BoxShadow( BoxShadow(
color: accent.withValues(alpha: 0.5), color: accent.withValues(
blurRadius: 10, alpha: isClosest ? 0.55 : 0.35,
),
blurRadius: isClosest ? 10 : 6,
), ),
] ]
: null, : null,

View file

@ -1,6 +1,6 @@
name: fai_studio_flow_editor name: fai_studio_flow_editor
description: Swappable inline YAML editor for F∆I Studio flows. description: Swappable inline YAML editor for F∆I Studio flows.
version: 0.5.2 version: 0.5.3
publish_to: 'none' publish_to: 'none'
repository: https://git.flemming.ai/fai/studio-flow-editor repository: https://git.flemming.ai/fai/studio-flow-editor