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 toSide;
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({
required this.from,
required this.to,
required this.fromSide,
required this.toSide,
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
// of its centre, so we move the endpoint LEFT by
// radius; right-side port goes the other way.
final shortenedFrom = Offset(
seg.from.dx +
(seg.fromSide == EdgeSide.right ? portRadius : -portRadius),
seg.from.dy,
);
final shortenedTo = Offset(
seg.to.dx + (seg.toSide == EdgeSide.right ? portRadius : -portRadius),
seg.to.dy,
);
final fromShift = seg.shortenFrom
? (seg.fromSide == EdgeSide.right ? portRadius : -portRadius)
: 0.0;
final toShift = seg.shortenTo
? (seg.toSide == EdgeSide.right ? portRadius : -portRadius)
: 0.0;
final shortenedFrom = Offset(seg.from.dx + fromShift, seg.from.dy);
final shortenedTo = Offset(seg.to.dx + toShift, seg.to.dy);
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
// and accepts a drop on any input port.
_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
void initState() {
@ -201,6 +207,13 @@ class _FlowCanvasState extends State<FlowCanvas> {
fromSide: EdgeSide.right,
toSide: EdgeSide.left,
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,
@ -522,6 +535,7 @@ class _FlowCanvasState extends State<FlowCanvas> {
final p = _outputPortPosition(step.id, layout);
final key = '${step.id}:__out__';
yield _portDot(
portKey: key,
center: p,
isSource: true,
connected: connectedPorts.contains(key),
@ -542,6 +556,7 @@ class _FlowCanvasState extends State<FlowCanvas> {
final input = graph.inputs[fieldName]!;
final key = 'inputs:$fieldName';
yield _portDot(
portKey: key,
center: p,
isSource: true,
connected: connectedPorts.contains(key),
@ -564,6 +579,7 @@ class _FlowCanvasState extends State<FlowCanvas> {
final value = step.with_[field]?.toString() ?? '';
final wired = _isWiredExpression(value);
yield _portDot(
portKey: '${step.id}:$field',
center: p,
isSource: false,
connected: wired,
@ -582,6 +598,7 @@ class _FlowCanvasState extends State<FlowCanvas> {
final expr = graph.outputs[field] ?? '';
final wired = _isWiredExpression(expr);
yield _portDot(
portKey: 'outputs:$field',
center: p,
isSource: false,
connected: wired,
@ -717,6 +734,7 @@ class _FlowCanvasState extends State<FlowCanvas> {
}
Widget _portDot({
required String portKey,
required Offset center,
required bool isSource,
required bool connected,
@ -728,11 +746,16 @@ class _FlowCanvasState extends State<FlowCanvas> {
final dragging = _draft != null;
final isInputDuringDrag = dragging && !isSource;
final isClosest = isInputDuringDrag && _isClosestDropTarget(center);
// Size logic: drop-target halo when a drag is hovering
// 18 px; otherwise the standard 12 px port that
// matches NodeGeometry.portDotSize. One single dot per
// port no more separate inline/canvas dots.
final size = isClosest ? 18.0 : NodeGeometry.portDotSize;
final isHovered = _hoveredPort == portKey;
// Size scales with focus level:
// - drop-target halo (drag is over this port) 18 px
// - hover (mouse-over without dragging) 15 px
// - 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
// edge, OR it's the closest drop target mid-drag. Plain
// outlined circle when neither the operator sees at
@ -745,6 +768,12 @@ class _FlowCanvasState extends State<FlowCanvas> {
height: size,
child: MouseRegion(
cursor: isSource ? SystemMouseCursors.grab : SystemMouseCursors.cell,
onEnter: (_) => setState(() => _hoveredPort = portKey),
onExit: (_) {
if (_hoveredPort == portKey) {
setState(() => _hoveredPort = null);
}
},
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onPanStart: !isSource
@ -785,13 +814,15 @@ class _FlowCanvasState extends State<FlowCanvas> {
color: filled ? accent : theme.colorScheme.surface,
border: Border.all(
color: accent,
width: isClosest ? 2.5 : 1.8,
width: isClosest ? 2.5 : (isHovered ? 2.2 : 1.8),
),
boxShadow: isClosest
boxShadow: (isClosest || isHovered)
? [
BoxShadow(
color: accent.withValues(alpha: 0.5),
blurRadius: 10,
color: accent.withValues(
alpha: isClosest ? 0.55 : 0.35,
),
blurRadius: isClosest ? 10 : 6,
),
]
: null,