Skip to content
Merged
68 changes: 55 additions & 13 deletions packages/devtools_app/lib/src/shared/ui/hover.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,18 @@ 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;

/// The total maximum height of the [HoverCard] including content, title,
/// divider, vertical padding, and borders.
const _totalMaxHoverCardHeight =
_maxHoverCardContentHeight +
_hoverCardTitleHeight +
_hoverCardDividerHeight +
(denseSpacing * 2) +
(hoverCardBorderSize * 2);
Comment thread
kenzieschmoll marked this conversation as resolved.
Outdated

TextStyle get _hoverTitleTextStyle => fixBlurryText(
const TextStyle(
Expand Down Expand Up @@ -142,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;
Expand Down Expand Up @@ -179,18 +190,22 @@ class HoverCard {
if (title != null) ...[
SizedBox(
width: width,
height: _hoverCardTitleHeight,
child: Text(
title,
overflow: TextOverflow.ellipsis,
style: _hoverTitleTextStyle,
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,
),
),
Expand All @@ -215,14 +230,35 @@ 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,
hoverCardController: hoverCardController,
);

static Offset _calculateCardPositionFromPointerEvent(
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);

final maxY = math.max(
_hoverMargin,
overlaySize.height - _hoverMargin - _totalMaxHoverCardHeight,
);
final y = (event.position.dy + _hoverYOffset).clamp(_hoverMargin, maxY);

return Offset(x, y);
Comment thread
kenzieschmoll marked this conversation as resolved.
}

late OverlayEntry _overlayEntry;

bool _isRemoved = false;
Expand Down Expand Up @@ -510,7 +546,7 @@ class _HoverCardTooltipState extends State<HoverCardTooltip> {
title: hoverCardData.title,
contents: hoverCardData.contents,
width: hoverCardData.width,
position: _calculateTooltipPosition(hoverCardData.width),
position: _calculateCardPosition(hoverCardData.width),
hoverCardController: _hoverCardController,
),
);
Expand All @@ -537,13 +573,19 @@ class _HoverCardTooltipState extends State<HoverCardTooltip> {
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;

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,
);

final offset = box.localToGlobal(
box.size.bottomCenter(Offset.zero).translate(-width / 2, _hoverYOffset),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
114 changes: 114 additions & 0 deletions packages/devtools_app/test/shared/ui/hover_positioning_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// 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';

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
Comment thread
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);
},
);
}
Loading