diff --git a/packages/devtools_app/lib/src/shared/ui/hover.dart b/packages/devtools_app/lib/src/shared/ui/hover.dart index cc38795ac6f..b4431fa5718 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 'package:provider/provider.dart'; 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 @@ 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; @@ -179,6 +193,7 @@ class HoverCard { if (title != null) ...[ SizedBox( width: width, + height: _hoverCardTitleHeight, child: Text( title, overflow: TextOverflow.ellipsis, @@ -186,11 +201,16 @@ 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, ), ), @@ -215,14 +235,44 @@ class HoverCard { 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 @@ class _HoverCardTooltipState extends State { 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 @@ class _HoverCardTooltipState extends State { 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 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 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..79d2acd6904 --- /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 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.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)); + }, + ); +}