Skip to content

Commit 72bd7b3

Browse files
committed
Run hover, cursorEnter and cursorExit reactions
They were never wired: a tooltip shown on hover never appeared. A node with them gets a non-blocking MouseRegion that runs them on enter and exit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NNTVgYqUeBTGwAHuspt3D7
1 parent 935d1b4 commit 72bd7b3

2 files changed

Lines changed: 131 additions & 1 deletion

File tree

‎lib/src/transformers/widget_node_transformer_manager.dart‎

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,59 @@ abstract class WidgetNodeTransformerManager
227227

228228
/// Convenience method to handle widget reactions.
229229
Widget wrapWithReaction(BuildContext context, BaseNode node, Widget widget) {
230+
return _wrapWithPointerReactions(
231+
context,
232+
node,
233+
_wrapWithTapReactions(context, node, widget),
234+
);
235+
}
236+
237+
/// Runs [node]'s hover and cursorEnter reactions when a mouse enters it
238+
/// and its cursorExit reactions when it leaves, e.g. to show a tooltip
239+
/// over a map pad. Doesn't take part in hit testing, so taps still reach
240+
/// the widget.
241+
Widget _wrapWithPointerReactions(
242+
BuildContext context,
243+
BaseNode node,
244+
Widget widget,
245+
) {
246+
if (node is! ReactionMixin || node is CanvasNode) return widget;
247+
List<Reaction> of(Set<TriggerType> types) => (node as ReactionMixin)
248+
.reactions
249+
.where((r) => types.contains(r.trigger.type) && r.action.enabled)
250+
.toList();
251+
final List<Reaction> enter = of({
252+
TriggerType.hover,
253+
TriggerType.cursorEnter,
254+
});
255+
final List<Reaction> exit = of({TriggerType.cursorExit});
256+
if (enter.isEmpty && exit.isEmpty) return widget;
257+
return MouseRegion(
258+
opaque: false,
259+
hitTestBehavior: HitTestBehavior.translucent,
260+
onEnter: enter.isEmpty
261+
? null
262+
: (_) => FunctionsRepository.triggerAction(
263+
context,
264+
enter.first.trigger.type,
265+
reactions: enter,
266+
),
267+
onExit: exit.isEmpty
268+
? null
269+
: (_) => FunctionsRepository.triggerAction(
270+
context,
271+
TriggerType.cursorExit,
272+
reactions: exit,
273+
),
274+
child: widget,
275+
);
276+
}
277+
278+
Widget _wrapWithTapReactions(
279+
BuildContext context,
280+
BaseNode node,
281+
Widget widget,
282+
) {
230283
if (node is! ReactionMixin) return widget;
231284
if (node case CanvasNode() || SpacerNode()) return widget;
232285
if (node is CustomPropertiesMixin &&
@@ -293,7 +346,6 @@ abstract class WidgetNodeTransformerManager
293346
if (onClickReactions.isEmpty && onLongPressReactions.isEmpty) {
294347
return widget;
295348
}
296-
// TODO: should handle TriggerType.hover and TriggerType.unhover too.
297349
return MouseRegion(
298350
cursor: SystemMouseCursors.click,
299351
hitTestBehavior: HitTestBehavior.opaque,

‎test/pointer_reactions_test.dart‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import 'package:codelessly_api/codelessly_api.dart';
2+
import 'package:codelessly_sdk/codelessly_sdk.dart';
3+
import 'package:flutter/material.dart';
4+
import 'package:flutter_test/flutter_test.dart';
5+
6+
Reaction reaction(TriggerType type) => Reaction(
7+
name: type.name,
8+
trigger: TriggerModel(type: type),
9+
action: LinkAction(url: ''),
10+
);
11+
12+
RectangleNode pad(List<Reaction> reactions) => RectangleNode(
13+
id: 'pad',
14+
name: 'Pad',
15+
basicBoxLocal: NodeBox(0, 0, 44, 30),
16+
reactions: reactions,
17+
);
18+
19+
void main() {
20+
final manager = PassiveNodeTransformerManager(
21+
(id) => throw StateError(id),
22+
(context, bounds, pageID, layoutID, canvasID) => const SizedBox(),
23+
);
24+
25+
Future<MouseRegion?> regionFor(
26+
WidgetTester tester,
27+
RectangleNode node,
28+
) async {
29+
await tester.pumpWidget(
30+
MaterialApp(
31+
home: Builder(
32+
builder: (context) => manager.wrapWithReaction(
33+
context,
34+
node,
35+
const SizedBox(key: Key('pad'), width: 44, height: 30),
36+
),
37+
),
38+
),
39+
);
40+
final regions = tester
41+
.widgetList<MouseRegion>(
42+
find.ancestor(
43+
of: find.byKey(const Key('pad')),
44+
matching: find.byType(MouseRegion),
45+
),
46+
)
47+
.where((r) => r.onEnter != null || r.onExit != null);
48+
return regions.firstOrNull;
49+
}
50+
51+
testWidgets('hover and cursor enter/exit reactions get a mouse region', (
52+
tester,
53+
) async {
54+
final MouseRegion? both = await regionFor(
55+
tester,
56+
pad([
57+
reaction(TriggerType.cursorEnter),
58+
reaction(TriggerType.cursorExit),
59+
]),
60+
);
61+
expect(both, isNotNull);
62+
expect(both!.onEnter, isNotNull);
63+
expect(both.onExit, isNotNull);
64+
expect(both.opaque, isFalse, reason: 'taps still reach the node');
65+
66+
final MouseRegion? hover = await regionFor(
67+
tester,
68+
pad([reaction(TriggerType.hover)]),
69+
);
70+
expect(hover?.onEnter, isNotNull);
71+
expect(hover?.onExit, isNull);
72+
});
73+
74+
testWidgets('a node without pointer reactions gets none', (tester) async {
75+
expect(await regionFor(tester, pad([reaction(TriggerType.click)])), isNull);
76+
expect(await regionFor(tester, pad([])), isNull);
77+
});
78+
}

0 commit comments

Comments
 (0)