Skip to content
Merged
85 changes: 73 additions & 12 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,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(
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -179,18 +193,24 @@ 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 +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);
Comment thread
kenzieschmoll marked this conversation as resolved.
}

late OverlayEntry _overlayEntry;

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

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