-
Notifications
You must be signed in to change notification settings - Fork 392
Fix HoverCard tooltip clipping in the Flutter Inspector #9823
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
auto-submit
merged 14 commits into
flutter:master
from
kenzieschmoll:fix-3920-tooltip-clipping
May 8, 2026
+305
−13
Merged
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
dd575ee
Fix HoverCard tooltip clipping in the Flutter Inspector
kenzieschmoll 05a64da
use var
kenzieschmoll f166072
Refactor HoverCard positioning for consistency and safety
kenzieschmoll cb3e495
gca comments
kenzieschmoll c9e7f27
renames
kenzieschmoll fcb0cee
Add release note for hover tooltip fix
kenzieschmoll a753631
Account for title and padding in HoverCard positioning
kenzieschmoll 8083fb2
fix consts
kenzieschmoll 7213e56
fix test
kenzieschmoll 010898f
fix test
kenzieschmoll 303dbbd
review comments
kenzieschmoll 499ec31
formatting
kenzieschmoll 8ba4fce
add another test case
kenzieschmoll 5f2c1e8
formatting
kenzieschmoll File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
231 changes: 231 additions & 0 deletions
231
packages/devtools_app/test/shared/ui/hover_positioning_test.dart
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
kenzieschmoll marked this conversation as resolved.
|
||
| 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)); | ||
| }, | ||
| ); | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.