Skip to content

Commit 24fba28

Browse files
Fix HoverCard tooltip clipping in the Flutter Inspector (#9823)
Prevents hover tooltips from being clipped by the window edges by implementing proper clamping and positioning logic in HoverCard. Fixes #3920
1 parent 645fcad commit 24fba28

3 files changed

Lines changed: 305 additions & 13 deletions

File tree

packages/devtools_app/lib/src/shared/ui/hover.dart

Lines changed: 73 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,21 @@ import 'package:provider/provider.dart';
1414
import 'common_widgets.dart';
1515
import 'utils.dart';
1616

17-
const _maxHoverCardHeight = 250.0;
17+
const _maxHoverCardContentHeight = 250.0;
18+
const _hoverCardTitleHeight = 24.0;
19+
const _hoverCardDividerHeight = 16.0;
20+
21+
/// Returns the total maximum height of the [HoverCard] including content,
22+
/// title (if present), divider, vertical padding, and borders.
23+
double _totalMaxHoverCardHeight({
24+
required bool hasTitle,
25+
double maxCardContentHeight = _maxHoverCardContentHeight,
26+
}) {
27+
return maxCardContentHeight +
28+
(hasTitle ? _hoverCardTitleHeight + _hoverCardDividerHeight : 0.0) +
29+
(denseSpacing * 2) +
30+
(hoverCardBorderSize * 2);
31+
}
1832

1933
TextStyle get _hoverTitleTextStyle => fixBlurryText(
2034
const TextStyle(
@@ -142,9 +156,9 @@ class HoverCard {
142156
required Offset position,
143157
required HoverCardController hoverCardController,
144158
String? title,
145-
double? maxCardHeight,
159+
double? maxCardContentHeight,
146160
}) {
147-
maxCardHeight ??= _maxHoverCardHeight;
161+
maxCardContentHeight ??= _maxHoverCardContentHeight;
148162
final overlayState = Overlay.of(context);
149163
final theme = Theme.of(context);
150164
final colorScheme = theme.colorScheme;
@@ -179,18 +193,24 @@ class HoverCard {
179193
if (title != null) ...[
180194
SizedBox(
181195
width: width,
196+
height: _hoverCardTitleHeight,
182197
child: Text(
183198
title,
184199
overflow: TextOverflow.ellipsis,
185200
style: _hoverTitleTextStyle,
186201
textAlign: TextAlign.center,
187202
),
188203
),
189-
Divider(color: theme.focusColor),
204+
Divider(
205+
color: theme.focusColor,
206+
height: _hoverCardDividerHeight,
207+
),
190208
],
191209
SingleChildScrollView(
192210
child: Container(
193-
constraints: BoxConstraints(maxHeight: maxCardHeight!),
211+
constraints: BoxConstraints(
212+
maxHeight: maxCardContentHeight!,
213+
),
194214
child: contents,
195215
),
196216
),
@@ -215,14 +235,44 @@ class HoverCard {
215235
context: context,
216236
contents: contents,
217237
width: width,
218-
position: Offset(
219-
math.max(0, event.position.dx - (width / 2.0)),
220-
event.position.dy + _hoverYOffset,
238+
position: _calculateCardPositionFromPointerEvent(
239+
context,
240+
event,
241+
width,
242+
title: title,
221243
),
222244
title: title,
223245
hoverCardController: hoverCardController,
224246
);
225247

248+
static Offset _calculateCardPositionFromPointerEvent(
249+
BuildContext context,
250+
PointerHoverEvent event,
251+
double width, {
252+
String? title,
253+
}) {
254+
final overlayBox =
255+
Overlay.of(context).context.findRenderObject() as RenderBox;
256+
final overlaySize = overlayBox.size;
257+
final localPosition = overlayBox.globalToLocal(event.position);
258+
259+
final maxX = math.max(
260+
_hoverMargin,
261+
overlaySize.width - _hoverMargin - width,
262+
);
263+
final x = (localPosition.dx - (width / 2.0)).clamp(_hoverMargin, maxX);
264+
265+
final maxY = math.max(
266+
_hoverMargin,
267+
overlaySize.height -
268+
_hoverMargin -
269+
_totalMaxHoverCardHeight(hasTitle: title != null),
270+
);
271+
final y = (localPosition.dy + _hoverYOffset).clamp(_hoverMargin, maxY);
272+
273+
return Offset(x, y);
274+
}
275+
226276
late OverlayEntry _overlayEntry;
227277

228278
bool _isRemoved = false;
@@ -510,7 +560,10 @@ class _HoverCardTooltipState extends State<HoverCardTooltip> {
510560
title: hoverCardData.title,
511561
contents: hoverCardData.contents,
512562
width: hoverCardData.width,
513-
position: _calculateTooltipPosition(hoverCardData.width),
563+
position: _calculateCardPosition(
564+
hoverCardData.width,
565+
title: hoverCardData.title,
566+
),
514567
hoverCardController: _hoverCardController,
515568
),
516569
);
@@ -537,13 +590,21 @@ class _HoverCardTooltipState extends State<HoverCardTooltip> {
537590
return completer;
538591
}
539592

540-
Offset _calculateTooltipPosition(double width) {
593+
Offset _calculateCardPosition(double width, {String? title}) {
541594
final overlayBox =
542595
Overlay.of(context).context.findRenderObject() as RenderBox;
543596
final box = context.findRenderObject() as RenderBox;
544597

545-
final maxX = overlayBox.size.width - _hoverMargin - width;
546-
final maxY = overlayBox.size.height - _hoverMargin;
598+
final maxX = math.max(
599+
_hoverMargin,
600+
overlayBox.size.width - _hoverMargin - width,
601+
);
602+
final maxY = math.max(
603+
_hoverMargin,
604+
overlayBox.size.height -
605+
_hoverMargin -
606+
_totalMaxHoverCardHeight(hasTitle: title != null),
607+
);
547608

548609
final offset = box.localToGlobal(
549610
box.size.bottomCenter(Offset.zero).translate(-width / 2, _hoverYOffset),

packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ TODO: Remove this section if there are not any updates.
1919

2020
## Inspector updates
2121

22-
TODO: Remove this section if there are not any updates.
22+
- Fixed an issue where hover tooltips in the widget tree were being clipped by the window boundaries. [#9823](https://github.com/flutter/devtools/pull/9823)
2323

2424
## Performance updates
2525

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
// Copyright 2026 The Flutter Authors
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
4+
5+
import 'package:devtools_app/src/shared/ui/hover.dart';
6+
import 'package:devtools_test/helpers.dart';
7+
import 'package:flutter/gestures.dart';
8+
import 'package:flutter/material.dart';
9+
import 'package:flutter_test/flutter_test.dart';
10+
import 'package:provider/provider.dart';
11+
12+
void main() {
13+
Future<void> pumpHoverCardTooltip(
14+
WidgetTester tester, {
15+
required Alignment alignment,
16+
String? title,
17+
}) async {
18+
await tester.pumpWidget(
19+
wrapSimple(
20+
Align(
21+
alignment: alignment,
22+
child: HoverCardTooltip.sync(
23+
enabled: () => true,
24+
generateHoverCardData: (event) => HoverCardData(
25+
title: title,
26+
contents: const SizedBox(
27+
width: 200,
28+
height: 250,
29+
child: Text('Hover Content'),
30+
),
31+
),
32+
child: const Text('Hover Me'),
33+
),
34+
),
35+
),
36+
);
37+
38+
// Trigger hover
39+
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
40+
await gesture.addPointer(location: Offset.zero);
41+
final center = tester.getCenter(find.text('Hover Me'));
42+
await gesture.moveTo(center);
43+
await tester.pump(const Duration(milliseconds: 500));
44+
await tester.pumpAndSettle();
45+
}
46+
47+
testWidgetsWithWindowSize(
48+
'HoverCard at the bottom of the window should not overflow',
49+
const Size(800, 600),
50+
(WidgetTester tester) async {
51+
// Use a title to increase the height beyond the base content height.
52+
await pumpHoverCardTooltip(
53+
tester,
54+
alignment: Alignment.bottomCenter,
55+
title: 'A Very Important Title',
56+
);
57+
58+
final hoverContentFinder = find.text('Hover Content');
59+
expect(hoverContentFinder, findsOneWidget);
60+
61+
final overlayContainer = find
62+
.ancestor(of: hoverContentFinder, matching: find.byType(Container))
63+
.last; // The outermost container of the HoverCard
64+
65+
final renderBox = tester.renderObject(overlayContainer) as RenderBox;
66+
final position = renderBox.localToGlobal(Offset.zero);
67+
final size = renderBox.size;
68+
69+
// _hoverMargin = 16.0
70+
expect(position.dy + size.height, lessThanOrEqualTo(600.0 - 16.0));
71+
},
72+
);
73+
74+
testWidgetsWithWindowSize(
75+
'HoverCard at the right of the window should not overflow',
76+
const Size(800, 600),
77+
(WidgetTester tester) async {
78+
await pumpHoverCardTooltip(tester, alignment: Alignment.centerRight);
79+
80+
final hoverContentFinder = find.text('Hover Content');
81+
expect(hoverContentFinder, findsOneWidget);
82+
83+
final overlayContainer = find
84+
.ancestor(of: hoverContentFinder, matching: find.byType(Container))
85+
.last;
86+
87+
final renderBox = tester.renderObject(overlayContainer) as RenderBox;
88+
final position = renderBox.localToGlobal(Offset.zero);
89+
final size = renderBox.size;
90+
91+
// _hoverMargin = 16.0
92+
expect(position.dx + size.width, lessThanOrEqualTo(800.0 - 16.0));
93+
},
94+
);
95+
96+
testWidgetsWithWindowSize(
97+
'HoverCard in very small window should not crash',
98+
const Size(100, 100), // Smaller than tooltip
99+
(WidgetTester tester) async {
100+
await pumpHoverCardTooltip(tester, alignment: Alignment.center);
101+
102+
final hoverContentFinder = find.text('Hover Content');
103+
expect(hoverContentFinder, findsOneWidget);
104+
105+
final overlayContainer = find
106+
.ancestor(of: hoverContentFinder, matching: find.byType(Container))
107+
.last;
108+
109+
expect(overlayContainer, findsOneWidget);
110+
},
111+
);
112+
113+
testWidgetsWithWindowSize(
114+
'HoverCard height clamping with title',
115+
const Size(800, 600),
116+
(WidgetTester tester) async {
117+
await pumpHoverCardTooltip(
118+
tester,
119+
alignment: Alignment.bottomCenter,
120+
title: 'An Important Title',
121+
);
122+
123+
final hoverContentFinderWithTitle = find.text('Hover Content');
124+
expect(hoverContentFinderWithTitle, findsOneWidget);
125+
126+
final containerWithTitle = find
127+
.ancestor(
128+
of: hoverContentFinderWithTitle,
129+
matching: find.byType(Container),
130+
)
131+
.last;
132+
133+
final renderBoxWithTitle =
134+
tester.renderObject(containerWithTitle) as RenderBox;
135+
final positionWithTitle = renderBoxWithTitle.localToGlobal(Offset.zero);
136+
137+
// Clamps strictly at y = 274.0 because of dynamic height containing title/divider.
138+
expect(positionWithTitle.dy, equals(274.0));
139+
},
140+
);
141+
142+
testWidgetsWithWindowSize(
143+
'HoverCard height clamping without title',
144+
const Size(800, 600),
145+
(WidgetTester tester) async {
146+
await pumpHoverCardTooltip(tester, alignment: Alignment.bottomCenter);
147+
148+
final hoverContentFinderNoTitle = find.text('Hover Content');
149+
expect(hoverContentFinderNoTitle, findsOneWidget);
150+
151+
final containerNoTitle = find
152+
.ancestor(
153+
of: hoverContentFinderNoTitle,
154+
matching: find.byType(Container),
155+
)
156+
.last;
157+
158+
final renderBoxNoTitle =
159+
tester.renderObject(containerNoTitle) as RenderBox;
160+
final positionNoTitle = renderBoxNoTitle.localToGlobal(Offset.zero);
161+
162+
// Clamps lower down at y = 314.0 because max height is smaller without title gaps.
163+
expect(positionNoTitle.dy, equals(314.0));
164+
},
165+
);
166+
167+
testWidgetsWithWindowSize(
168+
'HoverCard translates global coordinates to local coordinates for offset overlays',
169+
const Size(800, 600),
170+
(WidgetTester tester) async {
171+
final overlayKey = GlobalKey();
172+
173+
await tester.pumpWidget(
174+
MaterialApp(
175+
home: Scaffold(
176+
body: Padding(
177+
padding: const EdgeInsets.only(left: 50.0, top: 100.0),
178+
child: Provider<HoverCardController>.value(
179+
value: HoverCardController(),
180+
child: Overlay(
181+
key: overlayKey,
182+
initialEntries: [
183+
OverlayEntry(
184+
builder: (context) => Align(
185+
alignment: Alignment.topLeft,
186+
child: HoverCardTooltip.sync(
187+
enabled: () => true,
188+
generateHoverCardData: (event) => HoverCardData(
189+
contents: const SizedBox(
190+
width: 200,
191+
height: 250,
192+
child: Text('Hover Content'),
193+
),
194+
),
195+
child: const Text('Hover Me Offset'),
196+
),
197+
),
198+
),
199+
],
200+
),
201+
),
202+
),
203+
),
204+
),
205+
);
206+
207+
// Trigger hover
208+
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
209+
await gesture.addPointer(location: Offset.zero);
210+
211+
final center = tester.getCenter(find.text('Hover Me Offset'));
212+
await gesture.moveTo(center);
213+
await tester.pump(const Duration(milliseconds: 500));
214+
await tester.pumpAndSettle();
215+
216+
final hoverContentFinder = find.text('Hover Content');
217+
expect(hoverContentFinder, findsOneWidget);
218+
219+
final overlayContainer = find
220+
.ancestor(of: hoverContentFinder, matching: find.byType(Container))
221+
.last;
222+
223+
final renderBox = tester.renderObject(overlayContainer) as RenderBox;
224+
final position = renderBox.localToGlobal(Offset.zero);
225+
226+
// Dynamic margin is 16.0. Since overlay is offset by 50px globally at the left,
227+
// dynamic local X is 16.0, mapped to global X = 50.0 + 16.0 = 66.0.
228+
expect(position.dx, equals(66.0));
229+
},
230+
);
231+
}

0 commit comments

Comments
 (0)