Fix HoverCard tooltip clipping in the Flutter Inspector (#9823)

Prevents hover tooltips from being clipped by the window edges by implementing proper clamping and positioning logic in HoverCard. 

Fixes #3920
diff --git a/packages/devtools_app/lib/src/shared/ui/hover.dart b/packages/devtools_app/lib/src/shared/ui/hover.dart
index cc38795..b4431fa 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 '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 @@
     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 @@
                   if (title != null) ...[
                     SizedBox(
                       width: width,
+                      height: _hoverCardTitleHeight,
                       child: Text(
                         title,
                         overflow: TextOverflow.ellipsis,
@@ -186,11 +201,16 @@
                         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 @@
          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 @@
         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 @@
     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 de0d1ce..d80a29a 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 @@
 
 ## 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 0000000..79d2acd
--- /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<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
+      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));
+    },
+  );
+}