From dd575ee255838aec51b7c8740b9c20576bca604d Mon Sep 17 00:00:00 2001 From: Kenzie Davisson Date: Thu, 7 May 2026 09:46:31 -0700 Subject: [PATCH 01/14] Fix HoverCard tooltip clipping in the Flutter Inspector Prevents hover tooltips from being clipped by the window edges by implementing proper clamping and positioning logic in `HoverCard`. Fixes https://github.com/flutter/devtools/issues/3920 --- .../devtools_app/lib/src/shared/ui/hover.dart | 36 +++++- .../shared/ui/hover_positioning_test.dart | 116 ++++++++++++++++++ 2 files changed, 146 insertions(+), 6 deletions(-) create mode 100644 packages/devtools_app/test/shared/ui/hover_positioning_test.dart diff --git a/packages/devtools_app/lib/src/shared/ui/hover.dart b/packages/devtools_app/lib/src/shared/ui/hover.dart index cc38795ac6f..bdeb496d382 100644 --- a/packages/devtools_app/lib/src/shared/ui/hover.dart +++ b/packages/devtools_app/lib/src/shared/ui/hover.dart @@ -215,14 +215,34 @@ class HoverCard { context: context, contents: contents, width: width, - position: Offset( - math.max(0, event.position.dx - (width / 2.0)), - event.position.dy + _hoverYOffset, - ), + position: _calculateCursorPosition(context, event, width), title: title, hoverCardController: hoverCardController, ); + static Offset _calculateCursorPosition( + BuildContext context, + PointerHoverEvent event, + double width, + ) { + final overlayBox = + Overlay.of(context).context.findRenderObject() as RenderBox; + final overlaySize = overlayBox.size; + + final maxX = math.max(_hoverMargin, overlaySize.width - _hoverMargin - width); + final x = (event.position.dx - (width / 2.0)).clamp(_hoverMargin, maxX); + + double y = event.position.dy + _hoverYOffset; + if (y + _maxHoverCardHeight > overlaySize.height - _hoverMargin) { + y = math.max( + _hoverMargin, + overlaySize.height - _maxHoverCardHeight - _hoverMargin, + ); + } + + return Offset(x, y); + } + late OverlayEntry _overlayEntry; bool _isRemoved = false; @@ -542,8 +562,12 @@ class _HoverCardTooltipState extends State { 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); + double maxY = overlayBox.size.height - _hoverMargin; + if (maxY - _maxHoverCardHeight > _hoverMargin) { + maxY -= _maxHoverCardHeight; + } + maxY = math.max(_hoverMargin, maxY); final offset = box.localToGlobal( box.size.bottomCenter(Offset.zero).translate(-width / 2, _hoverYOffset), 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 00000000000..e08f8391477 --- /dev/null +++ b/packages/devtools_app/test/shared/ui/hover_positioning_test.dart @@ -0,0 +1,116 @@ +// Copyright 2024 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_app_shared/ui.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() { + late HoverCardController hoverCardController; + + setUp(() { + hoverCardController = HoverCardController(); + }); + + Future pumpHoverCardTooltip( + WidgetTester tester, { + required Alignment alignment, + Size windowSize = const Size(800, 600), + }) async { + await tester.binding.setSurfaceSize(windowSize); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Provider.value( + value: hoverCardController, + child: Align( + alignment: alignment, + child: HoverCardTooltip.sync( + enabled: () => true, + generateHoverCardData: (event) => HoverCardData( + contents: const SizedBox( + width: 200, + height: 200, + 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(); + } + + testWidgets('HoverCard at the bottom of the window should not overflow', (WidgetTester tester) async { + const windowSize = Size(800, 600); + await pumpHoverCardTooltip(tester, alignment: Alignment.bottomCenter, windowSize: windowSize); + + final hoverContentFinder = find.text('Hover Content'); + expect(hoverContentFinder, findsOneWidget); + + final overlayMouseRegion = find.ancestor( + of: hoverContentFinder, + matching: find.byType(MouseRegion), + ); + + expect(overlayMouseRegion, findsOneWidget); + + final renderBox = tester.renderObject(overlayMouseRegion) as RenderBox; + final position = renderBox.localToGlobal(Offset.zero); + final size = renderBox.size; + + expect(position.dy + size.height, lessThanOrEqualTo(windowSize.height)); + }); + + testWidgets('HoverCard at the right of the window should not overflow', (WidgetTester tester) async { + const windowSize = Size(800, 600); + await pumpHoverCardTooltip(tester, alignment: Alignment.centerRight, windowSize: windowSize); + + final hoverContentFinder = find.text('Hover Content'); + expect(hoverContentFinder, findsOneWidget); + + final overlayMouseRegion = find.ancestor( + of: hoverContentFinder, + matching: find.byType(MouseRegion), + ); + + expect(overlayMouseRegion, findsOneWidget); + + final renderBox = tester.renderObject(overlayMouseRegion) as RenderBox; + final position = renderBox.localToGlobal(Offset.zero); + final size = renderBox.size; + + expect(position.dx + size.width, lessThanOrEqualTo(windowSize.width)); + }); + + testWidgets('HoverCard in very small window should not crash', (WidgetTester tester) async { + const windowSize = Size(100, 100); // Smaller than tooltip + await pumpHoverCardTooltip(tester, alignment: Alignment.center, windowSize: windowSize); + + final hoverContentFinder = find.text('Hover Content'); + expect(hoverContentFinder, findsOneWidget); + + final overlayMouseRegion = find.ancestor( + of: hoverContentFinder, + matching: find.byType(MouseRegion), + ); + + expect(overlayMouseRegion, findsOneWidget); + }); +} From 05a64dae96736b057a609febf829aa6691786935 Mon Sep 17 00:00:00 2001 From: Kenzie Davisson Date: Thu, 7 May 2026 09:48:52 -0700 Subject: [PATCH 02/14] use var --- packages/devtools_app/lib/src/shared/ui/hover.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/devtools_app/lib/src/shared/ui/hover.dart b/packages/devtools_app/lib/src/shared/ui/hover.dart index bdeb496d382..f9778319936 100644 --- a/packages/devtools_app/lib/src/shared/ui/hover.dart +++ b/packages/devtools_app/lib/src/shared/ui/hover.dart @@ -232,7 +232,7 @@ class HoverCard { final maxX = math.max(_hoverMargin, overlaySize.width - _hoverMargin - width); final x = (event.position.dx - (width / 2.0)).clamp(_hoverMargin, maxX); - double y = event.position.dy + _hoverYOffset; + var y = event.position.dy + _hoverYOffset; if (y + _maxHoverCardHeight > overlaySize.height - _hoverMargin) { y = math.max( _hoverMargin, From f166072183527d3261308a45f603c8471741fb4a Mon Sep 17 00:00:00 2001 From: Kenzie Davisson Date: Thu, 7 May 2026 09:50:44 -0700 Subject: [PATCH 03/14] Refactor HoverCard positioning for consistency and safety --- .../devtools_app/lib/src/shared/ui/hover.dart | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/devtools_app/lib/src/shared/ui/hover.dart b/packages/devtools_app/lib/src/shared/ui/hover.dart index f9778319936..e87e9255489 100644 --- a/packages/devtools_app/lib/src/shared/ui/hover.dart +++ b/packages/devtools_app/lib/src/shared/ui/hover.dart @@ -229,16 +229,17 @@ class HoverCard { Overlay.of(context).context.findRenderObject() as RenderBox; final overlaySize = overlayBox.size; - final maxX = math.max(_hoverMargin, overlaySize.width - _hoverMargin - width); + final maxX = + math.max(_hoverMargin, overlaySize.width - _hoverMargin - width); final x = (event.position.dx - (width / 2.0)).clamp(_hoverMargin, maxX); - var y = event.position.dy + _hoverYOffset; - if (y + _maxHoverCardHeight > overlaySize.height - _hoverMargin) { - y = math.max( - _hoverMargin, - overlaySize.height - _maxHoverCardHeight - _hoverMargin, - ); + double maxY = overlaySize.height - _hoverMargin; + if (maxY - _maxHoverCardHeight > _hoverMargin) { + maxY -= _maxHoverCardHeight; } + maxY = math.max(_hoverMargin, maxY); + + final y = (event.position.dy + _hoverYOffset).clamp(_hoverMargin, maxY); return Offset(x, y); } From cb3e495f985e0c535e5edc75e27cba2d9dc25b96 Mon Sep 17 00:00:00 2001 From: Kenzie Davisson Date: Thu, 7 May 2026 09:59:20 -0700 Subject: [PATCH 04/14] gca comments --- .../devtools_app/lib/src/shared/ui/hover.dart | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/devtools_app/lib/src/shared/ui/hover.dart b/packages/devtools_app/lib/src/shared/ui/hover.dart index e87e9255489..6aef58febf1 100644 --- a/packages/devtools_app/lib/src/shared/ui/hover.dart +++ b/packages/devtools_app/lib/src/shared/ui/hover.dart @@ -229,16 +229,16 @@ class HoverCard { Overlay.of(context).context.findRenderObject() as RenderBox; final overlaySize = overlayBox.size; - final maxX = - math.max(_hoverMargin, overlaySize.width - _hoverMargin - width); + final maxX = math.max( + _hoverMargin, + overlaySize.width - _hoverMargin - width, + ); final x = (event.position.dx - (width / 2.0)).clamp(_hoverMargin, maxX); - double maxY = overlaySize.height - _hoverMargin; - if (maxY - _maxHoverCardHeight > _hoverMargin) { - maxY -= _maxHoverCardHeight; - } - maxY = math.max(_hoverMargin, maxY); - + final maxY = math.max( + _hoverMargin, + overlaySize.height - _hoverMargin - _maxHoverCardHeight, + ); final y = (event.position.dy + _hoverYOffset).clamp(_hoverMargin, maxY); return Offset(x, y); @@ -563,12 +563,14 @@ class _HoverCardTooltipState extends State { Overlay.of(context).context.findRenderObject() as RenderBox; final box = context.findRenderObject() as RenderBox; - final maxX = math.max(_hoverMargin, overlayBox.size.width - _hoverMargin - width); - double maxY = overlayBox.size.height - _hoverMargin; - if (maxY - _maxHoverCardHeight > _hoverMargin) { - maxY -= _maxHoverCardHeight; - } - maxY = math.max(_hoverMargin, maxY); + final maxX = math.max( + _hoverMargin, + overlayBox.size.width - _hoverMargin - width, + ); + final maxY = math.max( + _hoverMargin, + overlayBox.size.height - _hoverMargin - _maxHoverCardHeight, + ); final offset = box.localToGlobal( box.size.bottomCenter(Offset.zero).translate(-width / 2, _hoverYOffset), From c9e7f27948b7c4dc35a36ce6e8a5f29476406094 Mon Sep 17 00:00:00 2001 From: Kenzie Davisson Date: Thu, 7 May 2026 10:01:31 -0700 Subject: [PATCH 05/14] renames --- packages/devtools_app/lib/src/shared/ui/hover.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/devtools_app/lib/src/shared/ui/hover.dart b/packages/devtools_app/lib/src/shared/ui/hover.dart index 6aef58febf1..dd5648a09b8 100644 --- a/packages/devtools_app/lib/src/shared/ui/hover.dart +++ b/packages/devtools_app/lib/src/shared/ui/hover.dart @@ -215,12 +215,12 @@ class HoverCard { context: context, contents: contents, width: width, - position: _calculateCursorPosition(context, event, width), + position: _calculateCardPositionFromPointerEvent(context, event, width), title: title, hoverCardController: hoverCardController, ); - static Offset _calculateCursorPosition( + static Offset _calculateCardPositionFromPointerEvent( BuildContext context, PointerHoverEvent event, double width, @@ -531,7 +531,7 @@ class _HoverCardTooltipState extends State { title: hoverCardData.title, contents: hoverCardData.contents, width: hoverCardData.width, - position: _calculateTooltipPosition(hoverCardData.width), + position: _calculateCardPosition(hoverCardData.width), hoverCardController: _hoverCardController, ), ); @@ -558,7 +558,7 @@ class _HoverCardTooltipState extends State { return completer; } - Offset _calculateTooltipPosition(double width) { + Offset _calculateCardPosition(double width) { final overlayBox = Overlay.of(context).context.findRenderObject() as RenderBox; final box = context.findRenderObject() as RenderBox; From fcb0ceefb86f6207ae98eb21e940191e67ab21be Mon Sep 17 00:00:00 2001 From: Kenzie Davisson Date: Thu, 7 May 2026 10:15:54 -0700 Subject: [PATCH 06/14] Add release note for hover tooltip fix --- packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md index de0d1ce7f5a..d80a29abfc3 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 @@ TODO: Remove this section if there are not any updates. ## 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 From a753631b57ddfb0cd8a9a4107b8f0cf119efb624 Mon Sep 17 00:00:00 2001 From: Kenzie Davisson Date: Thu, 7 May 2026 10:26:37 -0700 Subject: [PATCH 07/14] Account for title and padding in HoverCard positioning --- .../devtools_app/lib/src/shared/ui/hover.dart | 15 +++++- .../shared/ui/hover_positioning_test.dart | 46 +++++++++++-------- 2 files changed, 39 insertions(+), 22 deletions(-) diff --git a/packages/devtools_app/lib/src/shared/ui/hover.dart b/packages/devtools_app/lib/src/shared/ui/hover.dart index dd5648a09b8..470af1448de 100644 --- a/packages/devtools_app/lib/src/shared/ui/hover.dart +++ b/packages/devtools_app/lib/src/shared/ui/hover.dart @@ -15,6 +15,17 @@ import 'common_widgets.dart'; import 'utils.dart'; const _maxHoverCardHeight = 250.0; +const _hoverCardTitleHeight = 30.0; +const _hoverCardDividerHeight = 16.0; +const _hoverCardPadding = 8.0; + +/// The total maximum height of the [HoverCard] including content, title, +/// divider, and padding. +const _totalMaxHoverCardHeight = + _maxHoverCardHeight + + _hoverCardTitleHeight + + _hoverCardDividerHeight + + _hoverCardPadding; TextStyle get _hoverTitleTextStyle => fixBlurryText( const TextStyle( @@ -237,7 +248,7 @@ class HoverCard { final maxY = math.max( _hoverMargin, - overlaySize.height - _hoverMargin - _maxHoverCardHeight, + overlaySize.height - _hoverMargin - _totalMaxHoverCardHeight, ); final y = (event.position.dy + _hoverYOffset).clamp(_hoverMargin, maxY); @@ -569,7 +580,7 @@ class _HoverCardTooltipState extends State { ); final maxY = math.max( _hoverMargin, - overlayBox.size.height - _hoverMargin - _maxHoverCardHeight, + overlayBox.size.height - _hoverMargin - _totalMaxHoverCardHeight, ); final offset = box.localToGlobal( diff --git a/packages/devtools_app/test/shared/ui/hover_positioning_test.dart b/packages/devtools_app/test/shared/ui/hover_positioning_test.dart index e08f8391477..62d74be2af6 100644 --- a/packages/devtools_app/test/shared/ui/hover_positioning_test.dart +++ b/packages/devtools_app/test/shared/ui/hover_positioning_test.dart @@ -20,6 +20,7 @@ void main() { WidgetTester tester, { required Alignment alignment, Size windowSize = const Size(800, 600), + String? title, }) async { await tester.binding.setSurfaceSize(windowSize); addTearDown(() => tester.binding.setSurfaceSize(null)); @@ -34,9 +35,10 @@ void main() { child: HoverCardTooltip.sync( enabled: () => true, generateHoverCardData: (event) => HoverCardData( + title: title, contents: const SizedBox( width: 200, - height: 200, + height: 250, child: Text('Hover Content'), ), ), @@ -59,23 +61,28 @@ void main() { testWidgets('HoverCard at the bottom of the window should not overflow', (WidgetTester tester) async { const windowSize = Size(800, 600); - await pumpHoverCardTooltip(tester, alignment: Alignment.bottomCenter, windowSize: windowSize); + // Use a title to increase the height beyond the base content height. + await pumpHoverCardTooltip( + tester, + alignment: Alignment.bottomCenter, + windowSize: windowSize, + title: 'A Very Important Title', + ); final hoverContentFinder = find.text('Hover Content'); expect(hoverContentFinder, findsOneWidget); - final overlayMouseRegion = find.ancestor( + final overlayContainer = find.ancestor( of: hoverContentFinder, - matching: find.byType(MouseRegion), - ); - - expect(overlayMouseRegion, findsOneWidget); + matching: find.byType(Container), + ).last; // The outermost container of the HoverCard - final renderBox = tester.renderObject(overlayMouseRegion) as RenderBox; + final renderBox = tester.renderObject(overlayContainer) as RenderBox; final position = renderBox.localToGlobal(Offset.zero); final size = renderBox.size; - expect(position.dy + size.height, lessThanOrEqualTo(windowSize.height)); + // _hoverMargin = 16.0 + expect(position.dy + size.height, lessThanOrEqualTo(windowSize.height - 16.0)); }); testWidgets('HoverCard at the right of the window should not overflow', (WidgetTester tester) async { @@ -85,18 +92,17 @@ void main() { final hoverContentFinder = find.text('Hover Content'); expect(hoverContentFinder, findsOneWidget); - final overlayMouseRegion = find.ancestor( + final overlayContainer = find.ancestor( of: hoverContentFinder, - matching: find.byType(MouseRegion), - ); - - expect(overlayMouseRegion, findsOneWidget); + matching: find.byType(Container), + ).last; - final renderBox = tester.renderObject(overlayMouseRegion) as RenderBox; + final renderBox = tester.renderObject(overlayContainer) as RenderBox; final position = renderBox.localToGlobal(Offset.zero); final size = renderBox.size; - expect(position.dx + size.width, lessThanOrEqualTo(windowSize.width)); + // _hoverMargin = 16.0 + expect(position.dx + size.width, lessThanOrEqualTo(windowSize.width - 16.0)); }); testWidgets('HoverCard in very small window should not crash', (WidgetTester tester) async { @@ -106,11 +112,11 @@ void main() { final hoverContentFinder = find.text('Hover Content'); expect(hoverContentFinder, findsOneWidget); - final overlayMouseRegion = find.ancestor( + final overlayContainer = find.ancestor( of: hoverContentFinder, - matching: find.byType(MouseRegion), - ); + matching: find.byType(Container), + ).last; - expect(overlayMouseRegion, findsOneWidget); + expect(overlayContainer, findsOneWidget); }); } From 8083fb23ffd2edd3fb72c664c9dc7c5c9fe28bd9 Mon Sep 17 00:00:00 2001 From: Kenzie Davisson Date: Thu, 7 May 2026 10:34:14 -0700 Subject: [PATCH 08/14] fix consts --- .../devtools_app/lib/src/shared/ui/hover.dart | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/devtools_app/lib/src/shared/ui/hover.dart b/packages/devtools_app/lib/src/shared/ui/hover.dart index 470af1448de..998187f86b1 100644 --- a/packages/devtools_app/lib/src/shared/ui/hover.dart +++ b/packages/devtools_app/lib/src/shared/ui/hover.dart @@ -14,18 +14,18 @@ import 'package:provider/provider.dart'; import 'common_widgets.dart'; import 'utils.dart'; -const _maxHoverCardHeight = 250.0; -const _hoverCardTitleHeight = 30.0; +const _maxHoverCardContentHeight = 250.0; +const _hoverCardTitleHeight = 24.0; const _hoverCardDividerHeight = 16.0; -const _hoverCardPadding = 8.0; /// The total maximum height of the [HoverCard] including content, title, -/// divider, and padding. +/// divider, vertical padding, and borders. const _totalMaxHoverCardHeight = - _maxHoverCardHeight + + _maxHoverCardContentHeight + _hoverCardTitleHeight + _hoverCardDividerHeight + - _hoverCardPadding; + (denseSpacing * 2) + + (hoverCardBorderSize * 2); TextStyle get _hoverTitleTextStyle => fixBlurryText( const TextStyle( @@ -153,9 +153,9 @@ class HoverCard { 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; @@ -190,6 +190,7 @@ class HoverCard { if (title != null) ...[ SizedBox( width: width, + height: _hoverCardTitleHeight, child: Text( title, overflow: TextOverflow.ellipsis, @@ -197,11 +198,14 @@ class HoverCard { 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, ), ), From 7213e5616f7dd534c4eb22e1029cb3ee6d7f63fb Mon Sep 17 00:00:00 2001 From: Kenzie Davisson Date: Thu, 7 May 2026 10:46:31 -0700 Subject: [PATCH 09/14] fix test --- .../devtools_app/test/shared/ui/hover_positioning_test.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/devtools_app/test/shared/ui/hover_positioning_test.dart b/packages/devtools_app/test/shared/ui/hover_positioning_test.dart index 62d74be2af6..bc87b56b46b 100644 --- a/packages/devtools_app/test/shared/ui/hover_positioning_test.dart +++ b/packages/devtools_app/test/shared/ui/hover_positioning_test.dart @@ -1,9 +1,8 @@ -// Copyright 2024 The Flutter Authors +// 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_app_shared/ui.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; From 010898f529a0902fe67a68d967fc52c4e6b237a6 Mon Sep 17 00:00:00 2001 From: Kenzie Davisson Date: Thu, 7 May 2026 11:00:32 -0700 Subject: [PATCH 10/14] fix test --- .../shared/ui/hover_positioning_test.dart | 169 +++++++++--------- 1 file changed, 81 insertions(+), 88 deletions(-) diff --git a/packages/devtools_app/test/shared/ui/hover_positioning_test.dart b/packages/devtools_app/test/shared/ui/hover_positioning_test.dart index bc87b56b46b..af3ebfc0698 100644 --- a/packages/devtools_app/test/shared/ui/hover_positioning_test.dart +++ b/packages/devtools_app/test/shared/ui/hover_positioning_test.dart @@ -3,47 +3,32 @@ // 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() { - late HoverCardController hoverCardController; - - setUp(() { - hoverCardController = HoverCardController(); - }); - Future pumpHoverCardTooltip( WidgetTester tester, { required Alignment alignment, - Size windowSize = const Size(800, 600), String? title, }) async { - await tester.binding.setSurfaceSize(windowSize); - addTearDown(() => tester.binding.setSurfaceSize(null)); - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Provider.value( - value: hoverCardController, - child: 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'), + 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'), ), ), ), @@ -58,64 +43,72 @@ void main() { await tester.pumpAndSettle(); } - testWidgets('HoverCard at the bottom of the window should not overflow', (WidgetTester tester) async { - const windowSize = Size(800, 600); - // Use a title to increase the height beyond the base content height. - await pumpHoverCardTooltip( - tester, - alignment: Alignment.bottomCenter, - windowSize: windowSize, - 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(windowSize.height - 16.0)); - }); - - testWidgets('HoverCard at the right of the window should not overflow', (WidgetTester tester) async { - const windowSize = Size(800, 600); - await pumpHoverCardTooltip(tester, alignment: Alignment.centerRight, windowSize: windowSize); - - 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(windowSize.width - 16.0)); - }); - - testWidgets('HoverCard in very small window should not crash', (WidgetTester tester) async { - const windowSize = Size(100, 100); // Smaller than tooltip - await pumpHoverCardTooltip(tester, alignment: Alignment.center, windowSize: windowSize); - - 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 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); + }, + ); } From 303dbbd1aaf388a09b70e67c366cd516e9696567 Mon Sep 17 00:00:00 2001 From: Kenzie Davisson Date: Thu, 7 May 2026 11:40:54 -0700 Subject: [PATCH 11/14] review comments --- .../devtools_app/lib/src/shared/ui/hover.dart | 54 +++++++++++++------ 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/packages/devtools_app/lib/src/shared/ui/hover.dart b/packages/devtools_app/lib/src/shared/ui/hover.dart index 998187f86b1..c5a3ec90b17 100644 --- a/packages/devtools_app/lib/src/shared/ui/hover.dart +++ b/packages/devtools_app/lib/src/shared/ui/hover.dart @@ -18,14 +18,17 @@ const _maxHoverCardContentHeight = 250.0; const _hoverCardTitleHeight = 24.0; const _hoverCardDividerHeight = 16.0; -/// The total maximum height of the [HoverCard] including content, title, -/// divider, vertical padding, and borders. -const _totalMaxHoverCardHeight = - _maxHoverCardContentHeight + - _hoverCardTitleHeight + - _hoverCardDividerHeight + - (denseSpacing * 2) + - (hoverCardBorderSize * 2); +/// 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( @@ -230,7 +233,12 @@ class HoverCard { context: context, contents: contents, width: width, - position: _calculateCardPositionFromPointerEvent(context, event, width), + position: _calculateCardPositionFromPointerEvent( + context, + event, + width, + title: title, + ), title: title, hoverCardController: hoverCardController, ); @@ -238,23 +246,27 @@ class HoverCard { static Offset _calculateCardPositionFromPointerEvent( BuildContext context, PointerHoverEvent event, - double width, - ) { + 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 = (event.position.dx - (width / 2.0)).clamp(_hoverMargin, maxX); + final x = (localPosition.dx - (width / 2.0)).clamp(_hoverMargin, maxX); final maxY = math.max( _hoverMargin, - overlaySize.height - _hoverMargin - _totalMaxHoverCardHeight, + overlaySize.height - + _hoverMargin - + _totalMaxHoverCardHeight(hasTitle: title != null), ); - final y = (event.position.dy + _hoverYOffset).clamp(_hoverMargin, maxY); + final y = (localPosition.dy + _hoverYOffset).clamp(_hoverMargin, maxY); return Offset(x, y); } @@ -546,7 +558,10 @@ class _HoverCardTooltipState extends State { title: hoverCardData.title, contents: hoverCardData.contents, width: hoverCardData.width, - position: _calculateCardPosition(hoverCardData.width), + position: _calculateCardPosition( + hoverCardData.width, + title: hoverCardData.title, + ), hoverCardController: _hoverCardController, ), ); @@ -573,7 +588,10 @@ class _HoverCardTooltipState extends State { return completer; } - Offset _calculateCardPosition(double width) { + Offset _calculateCardPosition( + double width, { + String? title, + }) { final overlayBox = Overlay.of(context).context.findRenderObject() as RenderBox; final box = context.findRenderObject() as RenderBox; @@ -584,7 +602,9 @@ class _HoverCardTooltipState extends State { ); final maxY = math.max( _hoverMargin, - overlayBox.size.height - _hoverMargin - _totalMaxHoverCardHeight, + overlayBox.size.height - + _hoverMargin - + _totalMaxHoverCardHeight(hasTitle: title != null), ); final offset = box.localToGlobal( From 499ec316a53754fededfef8269b686279cd2ed39 Mon Sep 17 00:00:00 2001 From: Kenzie Davisson Date: Thu, 7 May 2026 12:08:49 -0700 Subject: [PATCH 12/14] formatting --- .../devtools_app/lib/src/shared/ui/hover.dart | 9 ++++---- .../shared/ui/hover_positioning_test.dart | 21 ++++++++----------- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/packages/devtools_app/lib/src/shared/ui/hover.dart b/packages/devtools_app/lib/src/shared/ui/hover.dart index c5a3ec90b17..b4431fa5718 100644 --- a/packages/devtools_app/lib/src/shared/ui/hover.dart +++ b/packages/devtools_app/lib/src/shared/ui/hover.dart @@ -208,7 +208,9 @@ class HoverCard { ], SingleChildScrollView( child: Container( - constraints: BoxConstraints(maxHeight: maxCardContentHeight!), + constraints: BoxConstraints( + maxHeight: maxCardContentHeight!, + ), child: contents, ), ), @@ -588,10 +590,7 @@ class _HoverCardTooltipState extends State { return completer; } - Offset _calculateCardPosition( - double width, { - String? title, - }) { + Offset _calculateCardPosition(double width, {String? title}) { final overlayBox = Overlay.of(context).context.findRenderObject() as RenderBox; final box = context.findRenderObject() as RenderBox; diff --git a/packages/devtools_app/test/shared/ui/hover_positioning_test.dart b/packages/devtools_app/test/shared/ui/hover_positioning_test.dart index af3ebfc0698..7b81b25c98f 100644 --- a/packages/devtools_app/test/shared/ui/hover_positioning_test.dart +++ b/packages/devtools_app/test/shared/ui/hover_positioning_test.dart @@ -57,10 +57,9 @@ void main() { 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 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); @@ -80,10 +79,9 @@ void main() { final hoverContentFinder = find.text('Hover Content'); expect(hoverContentFinder, findsOneWidget); - final overlayContainer = find.ancestor( - of: hoverContentFinder, - matching: find.byType(Container), - ).last; + final overlayContainer = find + .ancestor(of: hoverContentFinder, matching: find.byType(Container)) + .last; final renderBox = tester.renderObject(overlayContainer) as RenderBox; final position = renderBox.localToGlobal(Offset.zero); @@ -103,10 +101,9 @@ void main() { final hoverContentFinder = find.text('Hover Content'); expect(hoverContentFinder, findsOneWidget); - final overlayContainer = find.ancestor( - of: hoverContentFinder, - matching: find.byType(Container), - ).last; + final overlayContainer = find + .ancestor(of: hoverContentFinder, matching: find.byType(Container)) + .last; expect(overlayContainer, findsOneWidget); }, From 8ba4fced9ed764bf1d5b07951940eb2b6143b1f8 Mon Sep 17 00:00:00 2001 From: Kenzie Davisson Date: Thu, 7 May 2026 12:20:25 -0700 Subject: [PATCH 13/14] add another test case --- .../shared/ui/hover_positioning_test.dart | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/packages/devtools_app/test/shared/ui/hover_positioning_test.dart b/packages/devtools_app/test/shared/ui/hover_positioning_test.dart index 7b81b25c98f..34c8b844249 100644 --- a/packages/devtools_app/test/shared/ui/hover_positioning_test.dart +++ b/packages/devtools_app/test/shared/ui/hover_positioning_test.dart @@ -7,6 +7,7 @@ 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 pumpHoverCardTooltip( @@ -108,4 +109,120 @@ void main() { 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.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)); + }, + ); } From 5f2c1e8b1f3c9b81444d1eb6fc31bffa59cb46ef Mon Sep 17 00:00:00 2001 From: Kenzie Davisson Date: Thu, 7 May 2026 14:45:37 -0700 Subject: [PATCH 14/14] formatting --- .../test/shared/ui/hover_positioning_test.dart | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/devtools_app/test/shared/ui/hover_positioning_test.dart b/packages/devtools_app/test/shared/ui/hover_positioning_test.dart index 34c8b844249..79d2acd6904 100644 --- a/packages/devtools_app/test/shared/ui/hover_positioning_test.dart +++ b/packages/devtools_app/test/shared/ui/hover_positioning_test.dart @@ -124,7 +124,10 @@ void main() { expect(hoverContentFinderWithTitle, findsOneWidget); final containerWithTitle = find - .ancestor(of: hoverContentFinderWithTitle, matching: find.byType(Container)) + .ancestor( + of: hoverContentFinderWithTitle, + matching: find.byType(Container), + ) .last; final renderBoxWithTitle = @@ -140,16 +143,16 @@ void main() { 'HoverCard height clamping without title', const Size(800, 600), (WidgetTester tester) async { - await pumpHoverCardTooltip( - tester, - alignment: Alignment.bottomCenter, - ); + 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)) + .ancestor( + of: hoverContentFinderNoTitle, + matching: find.byType(Container), + ) .last; final renderBoxNoTitle =