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
diff --git a/packages/devtools_app/lib/src/shared/ui/hover.dart b/packages/devtools_app/lib/src/shared/ui/hover.dart
index cc38795..b4431fa 100644
--- a/packages/devtools_app/lib/src/shared/ui/hover.dart
+++ b/packages/devtools_app/lib/src/shared/ui/hover.dart
@@ -14,7 +14,21 @@
import 'common_widgets.dart';
import 'utils.dart';
-const _maxHoverCardHeight = 250.0;
+const _maxHoverCardContentHeight = 250.0;
+const _hoverCardTitleHeight = 24.0;
+const _hoverCardDividerHeight = 16.0;
+
+/// Returns the total maximum height of the [HoverCard] including content,
+/// title (if present), divider, vertical padding, and borders.
+double _totalMaxHoverCardHeight({
+ required bool hasTitle,
+ double maxCardContentHeight = _maxHoverCardContentHeight,
+}) {
+ return maxCardContentHeight +
+ (hasTitle ? _hoverCardTitleHeight + _hoverCardDividerHeight : 0.0) +
+ (denseSpacing * 2) +
+ (hoverCardBorderSize * 2);
+}
TextStyle get _hoverTitleTextStyle => fixBlurryText(
const TextStyle(
@@ -142,9 +156,9 @@
required Offset position,
required HoverCardController hoverCardController,
String? title,
- double? maxCardHeight,
+ double? maxCardContentHeight,
}) {
- maxCardHeight ??= _maxHoverCardHeight;
+ maxCardContentHeight ??= _maxHoverCardContentHeight;
final overlayState = Overlay.of(context);
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
@@ -179,6 +193,7 @@
if (title != null) ...[
SizedBox(
width: width,
+ height: _hoverCardTitleHeight,
child: Text(
title,
overflow: TextOverflow.ellipsis,
@@ -186,11 +201,16 @@
textAlign: TextAlign.center,
),
),
- Divider(color: theme.focusColor),
+ Divider(
+ color: theme.focusColor,
+ height: _hoverCardDividerHeight,
+ ),
],
SingleChildScrollView(
child: Container(
- constraints: BoxConstraints(maxHeight: maxCardHeight!),
+ constraints: BoxConstraints(
+ maxHeight: maxCardContentHeight!,
+ ),
child: contents,
),
),
@@ -215,14 +235,44 @@
context: context,
contents: contents,
width: width,
- position: Offset(
- math.max(0, event.position.dx - (width / 2.0)),
- event.position.dy + _hoverYOffset,
+ position: _calculateCardPositionFromPointerEvent(
+ context,
+ event,
+ width,
+ title: title,
),
title: title,
hoverCardController: hoverCardController,
);
+ static Offset _calculateCardPositionFromPointerEvent(
+ BuildContext context,
+ PointerHoverEvent event,
+ double width, {
+ String? title,
+ }) {
+ final overlayBox =
+ Overlay.of(context).context.findRenderObject() as RenderBox;
+ final overlaySize = overlayBox.size;
+ final localPosition = overlayBox.globalToLocal(event.position);
+
+ final maxX = math.max(
+ _hoverMargin,
+ overlaySize.width - _hoverMargin - width,
+ );
+ final x = (localPosition.dx - (width / 2.0)).clamp(_hoverMargin, maxX);
+
+ final maxY = math.max(
+ _hoverMargin,
+ overlaySize.height -
+ _hoverMargin -
+ _totalMaxHoverCardHeight(hasTitle: title != null),
+ );
+ final y = (localPosition.dy + _hoverYOffset).clamp(_hoverMargin, maxY);
+
+ return Offset(x, y);
+ }
+
late OverlayEntry _overlayEntry;
bool _isRemoved = false;
@@ -510,7 +560,10 @@
title: hoverCardData.title,
contents: hoverCardData.contents,
width: hoverCardData.width,
- position: _calculateTooltipPosition(hoverCardData.width),
+ position: _calculateCardPosition(
+ hoverCardData.width,
+ title: hoverCardData.title,
+ ),
hoverCardController: _hoverCardController,
),
);
@@ -537,13 +590,21 @@
return completer;
}
- Offset _calculateTooltipPosition(double width) {
+ Offset _calculateCardPosition(double width, {String? title}) {
final overlayBox =
Overlay.of(context).context.findRenderObject() as RenderBox;
final box = context.findRenderObject() as RenderBox;
- final maxX = overlayBox.size.width - _hoverMargin - width;
- final maxY = overlayBox.size.height - _hoverMargin;
+ final maxX = math.max(
+ _hoverMargin,
+ overlayBox.size.width - _hoverMargin - width,
+ );
+ final maxY = math.max(
+ _hoverMargin,
+ overlayBox.size.height -
+ _hoverMargin -
+ _totalMaxHoverCardHeight(hasTitle: title != null),
+ );
final offset = box.localToGlobal(
box.size.bottomCenter(Offset.zero).translate(-width / 2, _hoverYOffset),
diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
index de0d1ce..d80a29a 100644
--- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
+++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
@@ -19,7 +19,7 @@
## Inspector updates
-TODO: Remove this section if there are not any updates.
+- 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)
## Performance updates
diff --git a/packages/devtools_app/test/shared/ui/hover_positioning_test.dart b/packages/devtools_app/test/shared/ui/hover_positioning_test.dart
new file mode 100644
index 0000000..79d2acd
--- /dev/null
+++ b/packages/devtools_app/test/shared/ui/hover_positioning_test.dart
@@ -0,0 +1,231 @@
+// Copyright 2026 The Flutter Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
+
+import 'package:devtools_app/src/shared/ui/hover.dart';
+import 'package:devtools_test/helpers.dart';
+import 'package:flutter/gestures.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:provider/provider.dart';
+
+void main() {
+ Future<void> pumpHoverCardTooltip(
+ WidgetTester tester, {
+ required Alignment alignment,
+ String? title,
+ }) async {
+ await tester.pumpWidget(
+ wrapSimple(
+ Align(
+ alignment: alignment,
+ child: HoverCardTooltip.sync(
+ enabled: () => true,
+ generateHoverCardData: (event) => HoverCardData(
+ title: title,
+ contents: const SizedBox(
+ width: 200,
+ height: 250,
+ child: Text('Hover Content'),
+ ),
+ ),
+ child: const Text('Hover Me'),
+ ),
+ ),
+ ),
+ );
+
+ // Trigger hover
+ final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
+ await gesture.addPointer(location: Offset.zero);
+ final center = tester.getCenter(find.text('Hover Me'));
+ await gesture.moveTo(center);
+ await tester.pump(const Duration(milliseconds: 500));
+ await tester.pumpAndSettle();
+ }
+
+ testWidgetsWithWindowSize(
+ 'HoverCard at the bottom of the window should not overflow',
+ const Size(800, 600),
+ (WidgetTester tester) async {
+ // Use a title to increase the height beyond the base content height.
+ await pumpHoverCardTooltip(
+ tester,
+ alignment: Alignment.bottomCenter,
+ title: 'A Very Important Title',
+ );
+
+ final hoverContentFinder = find.text('Hover Content');
+ expect(hoverContentFinder, findsOneWidget);
+
+ final overlayContainer = find
+ .ancestor(of: hoverContentFinder, matching: find.byType(Container))
+ .last; // The outermost container of the HoverCard
+
+ final renderBox = tester.renderObject(overlayContainer) as RenderBox;
+ final position = renderBox.localToGlobal(Offset.zero);
+ final size = renderBox.size;
+
+ // _hoverMargin = 16.0
+ expect(position.dy + size.height, lessThanOrEqualTo(600.0 - 16.0));
+ },
+ );
+
+ testWidgetsWithWindowSize(
+ 'HoverCard at the right of the window should not overflow',
+ const Size(800, 600),
+ (WidgetTester tester) async {
+ await pumpHoverCardTooltip(tester, alignment: Alignment.centerRight);
+
+ final hoverContentFinder = find.text('Hover Content');
+ expect(hoverContentFinder, findsOneWidget);
+
+ final overlayContainer = find
+ .ancestor(of: hoverContentFinder, matching: find.byType(Container))
+ .last;
+
+ final renderBox = tester.renderObject(overlayContainer) as RenderBox;
+ final position = renderBox.localToGlobal(Offset.zero);
+ final size = renderBox.size;
+
+ // _hoverMargin = 16.0
+ expect(position.dx + size.width, lessThanOrEqualTo(800.0 - 16.0));
+ },
+ );
+
+ testWidgetsWithWindowSize(
+ 'HoverCard in very small window should not crash',
+ const Size(100, 100), // Smaller than tooltip
+ (WidgetTester tester) async {
+ await pumpHoverCardTooltip(tester, alignment: Alignment.center);
+
+ final hoverContentFinder = find.text('Hover Content');
+ expect(hoverContentFinder, findsOneWidget);
+
+ final overlayContainer = find
+ .ancestor(of: hoverContentFinder, matching: find.byType(Container))
+ .last;
+
+ expect(overlayContainer, findsOneWidget);
+ },
+ );
+
+ testWidgetsWithWindowSize(
+ 'HoverCard height clamping with title',
+ const Size(800, 600),
+ (WidgetTester tester) async {
+ await pumpHoverCardTooltip(
+ tester,
+ alignment: Alignment.bottomCenter,
+ title: 'An Important Title',
+ );
+
+ final hoverContentFinderWithTitle = find.text('Hover Content');
+ expect(hoverContentFinderWithTitle, findsOneWidget);
+
+ final containerWithTitle = find
+ .ancestor(
+ of: hoverContentFinderWithTitle,
+ matching: find.byType(Container),
+ )
+ .last;
+
+ final renderBoxWithTitle =
+ tester.renderObject(containerWithTitle) as RenderBox;
+ final positionWithTitle = renderBoxWithTitle.localToGlobal(Offset.zero);
+
+ // Clamps strictly at y = 274.0 because of dynamic height containing title/divider.
+ expect(positionWithTitle.dy, equals(274.0));
+ },
+ );
+
+ testWidgetsWithWindowSize(
+ 'HoverCard height clamping without title',
+ const Size(800, 600),
+ (WidgetTester tester) async {
+ await pumpHoverCardTooltip(tester, alignment: Alignment.bottomCenter);
+
+ final hoverContentFinderNoTitle = find.text('Hover Content');
+ expect(hoverContentFinderNoTitle, findsOneWidget);
+
+ final containerNoTitle = find
+ .ancestor(
+ of: hoverContentFinderNoTitle,
+ matching: find.byType(Container),
+ )
+ .last;
+
+ final renderBoxNoTitle =
+ tester.renderObject(containerNoTitle) as RenderBox;
+ final positionNoTitle = renderBoxNoTitle.localToGlobal(Offset.zero);
+
+ // Clamps lower down at y = 314.0 because max height is smaller without title gaps.
+ expect(positionNoTitle.dy, equals(314.0));
+ },
+ );
+
+ testWidgetsWithWindowSize(
+ 'HoverCard translates global coordinates to local coordinates for offset overlays',
+ const Size(800, 600),
+ (WidgetTester tester) async {
+ final overlayKey = GlobalKey();
+
+ await tester.pumpWidget(
+ MaterialApp(
+ home: Scaffold(
+ body: Padding(
+ padding: const EdgeInsets.only(left: 50.0, top: 100.0),
+ child: Provider<HoverCardController>.value(
+ value: HoverCardController(),
+ child: Overlay(
+ key: overlayKey,
+ initialEntries: [
+ OverlayEntry(
+ builder: (context) => Align(
+ alignment: Alignment.topLeft,
+ child: HoverCardTooltip.sync(
+ enabled: () => true,
+ generateHoverCardData: (event) => HoverCardData(
+ contents: const SizedBox(
+ width: 200,
+ height: 250,
+ child: Text('Hover Content'),
+ ),
+ ),
+ child: const Text('Hover Me Offset'),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ // Trigger hover
+ final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
+ await gesture.addPointer(location: Offset.zero);
+
+ final center = tester.getCenter(find.text('Hover Me Offset'));
+ await gesture.moveTo(center);
+ await tester.pump(const Duration(milliseconds: 500));
+ await tester.pumpAndSettle();
+
+ final hoverContentFinder = find.text('Hover Content');
+ expect(hoverContentFinder, findsOneWidget);
+
+ final overlayContainer = find
+ .ancestor(of: hoverContentFinder, matching: find.byType(Container))
+ .last;
+
+ final renderBox = tester.renderObject(overlayContainer) as RenderBox;
+ final position = renderBox.localToGlobal(Offset.zero);
+
+ // Dynamic margin is 16.0. Since overlay is offset by 50px globally at the left,
+ // dynamic local X is 16.0, mapped to global X = 50.0 + 16.0 = 66.0.
+ expect(position.dx, equals(66.0));
+ },
+ );
+}