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:
parent
9afb51a4b5
commit
5c59f3a554
3 changed files with 59 additions and 19 deletions
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue