| // Copyright 2021 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import 'dart:async'; |
| import 'dart:math' as math; |
| |
| import 'package:flutter/gestures.dart'; |
| import 'package:flutter/material.dart'; |
| import 'package:provider/provider.dart'; |
| |
| import '../shared/common_widgets.dart'; |
| import '../shared/eval_on_dart_library.dart'; |
| import '../shared/theme.dart'; |
| import '../shared/utils.dart'; |
| import 'utils.dart'; |
| |
| /// Regex for valid Dart identifiers. |
| final _identifier = RegExp(r'^[a-zA-Z0-9]|_|\$'); |
| |
| /// Returns the word in the [line] for the provided hover [dx] offset given |
| /// the [line]'s [textStyle]. |
| String wordForHover(double dx, TextSpan line) { |
| String word = ''; |
| final hoverIndex = _hoverIndexFor(dx, line); |
| final lineText = line.toPlainText(); |
| if (hoverIndex >= 0 && hoverIndex < lineText.length) { |
| final hoverChar = lineText[hoverIndex]; |
| word = '$word$hoverChar'; |
| if (_identifier.hasMatch(hoverChar) || hoverChar == '.') { |
| // Merge trailing valid identifiers. |
| int charIndex = hoverIndex + 1; |
| while (charIndex < lineText.length) { |
| final character = lineText[charIndex]; |
| if (_identifier.hasMatch(character)) { |
| word = '$word$character'; |
| } else { |
| break; |
| } |
| charIndex++; |
| } |
| |
| // Merge preceding characters including those linked by a `.`. |
| charIndex = hoverIndex - 1; |
| while (charIndex >= 0) { |
| final character = lineText[charIndex]; |
| if (_identifier.hasMatch(character) || character == '.') { |
| word = '$character$word'; |
| } else { |
| break; |
| } |
| charIndex--; |
| } |
| } |
| } |
| |
| return word; |
| } |
| |
| /// Returns the index in the Textspan's plainText for which the hover offset is |
| /// located. |
| int _hoverIndexFor(double dx, TextSpan line) { |
| int hoverIndex = -1; |
| final length = line.toPlainText().length; |
| for (var i = 0; i < length; i++) { |
| final painter = TextPainter( |
| text: truncateTextSpan(line, i + 1), |
| textDirection: TextDirection.ltr, |
| )..layout(); |
| if (dx <= painter.width) { |
| hoverIndex = i; |
| break; |
| } |
| } |
| return hoverIndex; |
| } |
| |
| const _hoverYOffset = 10.0; |
| |
| /// Minimum distance from the side of screen to show tooltip |
| const _hoverMargin = 16.0; |
| |
| /// Defines how a [HoverCardTooltip] is positioned |
| enum HoverCardPosition { |
| /// Aligns the tooltip below the cursor |
| cursor, |
| |
| /// Aligns the tooltip to the element it's wrapped in |
| element, |
| } |
| |
| class HoverCardData { |
| HoverCardData({ |
| this.title, |
| required this.contents, |
| double? width, |
| this.position = HoverCardPosition.cursor, |
| }) : width = width ?? HoverCardTooltip.defaultHoverWidth; |
| |
| final String? title; |
| final Widget contents; |
| final double width; |
| final HoverCardPosition position; |
| } |
| |
| /// A card to display content while hovering over a widget. |
| /// |
| /// This widget will automatically remove itself after the mouse has entered |
| /// and left its region. |
| /// |
| /// Note that if a mouse has never entered, it will not remove itself. |
| class HoverCard { |
| HoverCard({ |
| required BuildContext context, |
| required Widget contents, |
| required double width, |
| required Offset position, |
| required HoverCardController hoverCardController, |
| String? title, |
| double? maxCardHeight, |
| }) { |
| maxCardHeight ??= maxHoverCardHeight; |
| final overlayState = Overlay.of(context); |
| final theme = Theme.of(context); |
| final colorScheme = theme.colorScheme; |
| final focusColor = theme.focusColor; |
| final hoverHeading = theme.hoverTitleTextStyle; |
| |
| _overlayEntry = OverlayEntry( |
| builder: (context) { |
| return Positioned( |
| left: position.dx, |
| top: position.dy, |
| child: MouseRegion( |
| onExit: (_) { |
| hoverCardController.removeHoverCard(this); |
| }, |
| onEnter: (_) { |
| _hasMouseEntered = true; |
| }, |
| child: Container( |
| padding: const EdgeInsets.all(denseSpacing), |
| decoration: BoxDecoration( |
| color: colorScheme.defaultBackgroundColor, |
| border: Border.all( |
| color: focusColor, |
| width: hoverCardBorderWidth, |
| ), |
| borderRadius: BorderRadius.circular(defaultBorderRadius), |
| ), |
| width: width, |
| child: Column( |
| crossAxisAlignment: CrossAxisAlignment.start, |
| children: [ |
| if (title != null) ...[ |
| Container( |
| width: width, |
| child: Text( |
| title, |
| overflow: TextOverflow.ellipsis, |
| style: hoverHeading, |
| textAlign: TextAlign.center, |
| ), |
| ), |
| Divider(color: theme.hoverTextStyle.color), |
| ], |
| SingleChildScrollView( |
| child: Container( |
| constraints: BoxConstraints(maxHeight: maxCardHeight!), |
| child: contents, |
| ), |
| ), |
| ], |
| ), |
| ), |
| ), |
| ); |
| }, |
| ); |
| overlayState.insert(_overlayEntry); |
| } |
| |
| HoverCard.fromHoverEvent({ |
| required BuildContext context, |
| required PointerHoverEvent event, |
| required Widget contents, |
| required double width, |
| required HoverCardController hoverCardController, |
| String? title, |
| }) : this( |
| context: context, |
| contents: contents, |
| width: width, |
| position: Offset( |
| math.max(0, event.position.dx - (width / 2.0)), |
| event.position.dy + _hoverYOffset, |
| ), |
| title: title, |
| hoverCardController: hoverCardController, |
| ); |
| |
| late OverlayEntry _overlayEntry; |
| |
| bool _isRemoved = false; |
| |
| bool _hasMouseEntered = false; |
| |
| /// Attempts to remove the HoverCard from the screen. |
| /// |
| /// The HoverCard will not be removed if the mouse is currently inside the |
| /// widget. |
| /// Returns whether or not the HoverCard was removed. |
| bool maybeRemove() { |
| if (!_hasMouseEntered) { |
| remove(); |
| return true; |
| } |
| return false; |
| } |
| |
| /// Removes the HoverCard even if the mouse is in the corresponding mouse |
| /// region. |
| void remove() { |
| if (!_isRemoved) _overlayEntry.remove(); |
| _isRemoved = true; |
| } |
| } |
| |
| /// Ensures that only one [HoverCard] is ever displayed at a time. |
| class HoverCardController { |
| /// The card that is currenty being displayed. |
| HoverCard? _currentHoverCard; |
| |
| /// Sets [hoverCard] as the most recently displayed [HoverCard]. |
| /// |
| /// [hoverCard] is the most recently displayed [HoverCard]. |
| void set({required HoverCard hoverCard}) { |
| _currentHoverCard?.remove(); |
| _currentHoverCard = hoverCard; |
| } |
| |
| /// If the mouse is outside of [_currentHoverCard] then then it will be removed. |
| void maybeRemoveHoverCard(HoverCard hoverCard) { |
| if (isHoverCardStillActive(hoverCard)) { |
| final wasRemoved = _currentHoverCard?.maybeRemove(); |
| if (wasRemoved == true) { |
| _currentHoverCard = null; |
| } |
| } |
| } |
| |
| /// Remove [hoverCard] if it is currently active. |
| void removeHoverCard(HoverCard hoverCard) { |
| if (isHoverCardStillActive(hoverCard)) { |
| _currentHoverCard?.remove(); |
| _currentHoverCard = null; |
| } |
| } |
| |
| /// Checks if the [hoverCard] is still the active [HoverCard]. |
| bool isHoverCardStillActive(HoverCard hoverCard) { |
| return _currentHoverCard == hoverCard; |
| } |
| } |
| |
| typedef AsyncGenerateHoverCardDataFunc = Future<HoverCardData?> Function({ |
| required PointerHoverEvent event, |
| |
| /// Returns true if the HoverCard is no longer visible. |
| /// |
| /// Use this callback to short circuit long running tasks. |
| required bool Function() isHoverStale, |
| }); |
| |
| typedef SyncGenerateHoverCardDataFunc = HoverCardData Function( |
| PointerHoverEvent event, |
| ); |
| |
| /// A hover card based tooltip. |
| class HoverCardTooltip extends StatefulWidget { |
| /// A [HoverCardTooltip] that generates it's [HoverCardData] asynchronously. |
| /// |
| /// [asyncGenerateHoverCardData] is used to generate the data that will |
| /// display in the final [HoverCard]. While that data is being generated, |
| /// a [HoverCard] with a spinner will show. If any [HoverCardData] returned |
| /// from [asyncGenerateHoverCardData] the spinner [HoverCard] will be replaced |
| /// with one containing the generated [HoverCardData]. |
| const HoverCardTooltip.async({ |
| required this.enabled, |
| required this.asyncGenerateHoverCardData, |
| required this.child, |
| this.disposable, |
| this.asyncTimeout, |
| }) : generateHoverCardData = null; |
| |
| /// A [HoverCardTooltip] that generates it's [HoverCardData] synchronously. |
| /// |
| /// The [HoverCardData] generated from [generateHoverCardData] will be |
| /// displayed in a [HoverCard]. |
| const HoverCardTooltip.sync({ |
| required this.enabled, |
| required this.generateHoverCardData, |
| required this.child, |
| this.disposable, |
| }) : asyncGenerateHoverCardData = null, |
| asyncTimeout = null; |
| |
| static const _hoverDelay = Duration(milliseconds: 500); |
| static double get defaultHoverWidth => scaleByFontFactor(450.0); |
| |
| /// Whether the tooltip is currently enabled. |
| final bool Function() enabled; |
| |
| /// The callback that is used when the [HoverCard]'s data is only available |
| /// asynchronously. |
| final AsyncGenerateHoverCardDataFunc? asyncGenerateHoverCardData; |
| |
| /// The callback that is used when the [HoverCard]'s data is available |
| /// synchronously. |
| final SyncGenerateHoverCardDataFunc? generateHoverCardData; |
| |
| final Widget child; |
| |
| /// Disposable object to be disposed when the group is closed. |
| final Disposable? disposable; |
| |
| /// If set, will only show the async hovercard after the timeout has elapsed. |
| final int? asyncTimeout; |
| |
| @override |
| _HoverCardTooltipState createState() => _HoverCardTooltipState(); |
| } |
| |
| class _HoverCardTooltipState extends State<HoverCardTooltip> { |
| /// A timer that shows a [HoverCard] with an evaluation result when completed. |
| Timer? _showTimer; |
| |
| /// A timer that removes a [HoverCard] when completed. |
| Timer? _removeTimer; |
| |
| HoverCard? _currentHoverCard; |
| |
| late HoverCardController _hoverCardController; |
| |
| void _onHoverExit() { |
| _showTimer?.cancel(); |
| _removeTimer = Timer(HoverCardTooltip._hoverDelay, () { |
| if (_currentHoverCard != null) { |
| _hoverCardController.maybeRemoveHoverCard(_currentHoverCard!); |
| } |
| }); |
| } |
| |
| void _setHoverCard(HoverCard hoverCard) { |
| if (!mounted) return; |
| _hoverCardController.set(hoverCard: hoverCard); |
| _currentHoverCard = hoverCard; |
| } |
| |
| void _removeHoverCard(HoverCard hoverCard) { |
| _hoverCardController.removeHoverCard(hoverCard); |
| } |
| |
| void _onHover(PointerHoverEvent event) { |
| _showTimer?.cancel(); |
| _showTimer = null; |
| _removeTimer?.cancel(); |
| _removeTimer = null; |
| |
| if (!widget.enabled()) return; |
| final asyncGenerateHoverCardData = widget.asyncGenerateHoverCardData; |
| final generateHoverCardData = widget.generateHoverCardData; |
| final asyncTimeout = widget.asyncTimeout; |
| |
| _showTimer = Timer(HoverCardTooltip._hoverDelay, () async { |
| if (asyncGenerateHoverCardData != null) { |
| assert(generateHoverCardData == null); |
| _showAsyncHoverCard( |
| asyncGenerateHoverCardData: asyncGenerateHoverCardData, |
| event: event, |
| asyncTimeout: asyncTimeout, |
| ); |
| } else { |
| _setHoverCardFromData( |
| generateHoverCardData!(event), |
| context: context, |
| event: event, |
| ); |
| } |
| }); |
| } |
| |
| void _showAsyncHoverCard({ |
| required AsyncGenerateHoverCardDataFunc asyncGenerateHoverCardData, |
| required PointerHoverEvent event, |
| int? asyncTimeout, |
| }) async { |
| HoverCard? spinnerHoverCard; |
| final hoverCardDataFuture = asyncGenerateHoverCardData( |
| event: event, |
| isHoverStale: () => |
| spinnerHoverCard != null && |
| !_hoverCardController.isHoverCardStillActive(spinnerHoverCard), |
| ); |
| final hoverCardDataCompleter = _hoverCardDataCompleter(hoverCardDataFuture); |
| // If we have set the async hover card to show up only after a timeout, |
| // then race the timeout against generating the hover card data. If |
| // generating the data completes first, immediately show the hover card |
| // (or return early if there is no data). |
| if (asyncTimeout != null) { |
| await Future.any([ |
| _timeoutCompleter(asyncTimeout).future, |
| hoverCardDataCompleter.future, |
| ]); |
| |
| if (hoverCardDataCompleter.isCompleted) { |
| final data = await hoverCardDataCompleter.future; |
| // If we get no data back, then don't show a hover card. |
| if (data == null) return; |
| // Otherwise, show a hover card immediately. |
| return _setHoverCardFromData( |
| data, |
| context: context, |
| event: event, |
| ); |
| } |
| } |
| // The data on the card is fetched asynchronously, so show a spinner |
| // while we wait for it. |
| spinnerHoverCard = HoverCard.fromHoverEvent( |
| context: context, |
| contents: const CenteredCircularProgressIndicator(), |
| width: HoverCardTooltip.defaultHoverWidth, |
| event: event, |
| hoverCardController: _hoverCardController, |
| ); |
| |
| _setHoverCard( |
| spinnerHoverCard, |
| ); |
| |
| // The spinner is showing, we can now generate the HoverCardData |
| final hoverCardData = await hoverCardDataCompleter.future; |
| |
| if (!_hoverCardController.isHoverCardStillActive(spinnerHoverCard)) { |
| // The hovercard became stale while fetching it's data. So it should |
| // no longer be shown. |
| return; |
| } |
| if (hoverCardData == null) { |
| // No data was provided so remove the spinner |
| _removeHoverCard(spinnerHoverCard); |
| return; |
| } |
| |
| return _setHoverCardFromData( |
| hoverCardData, |
| context: context, |
| event: event, |
| ); |
| } |
| |
| void _setHoverCardFromData( |
| HoverCardData hoverCardData, { |
| required BuildContext context, |
| required PointerHoverEvent event, |
| }) { |
| if (hoverCardData.position == HoverCardPosition.cursor) { |
| return _setHoverCard( |
| HoverCard.fromHoverEvent( |
| context: context, |
| title: hoverCardData.title, |
| contents: hoverCardData.contents, |
| width: hoverCardData.width, |
| event: event, |
| hoverCardController: _hoverCardController, |
| ), |
| ); |
| } |
| return _setHoverCard( |
| HoverCard( |
| context: context, |
| title: hoverCardData.title, |
| contents: hoverCardData.contents, |
| width: hoverCardData.width, |
| position: _calculateTooltipPosition(hoverCardData.width), |
| hoverCardController: _hoverCardController, |
| ), |
| ); |
| } |
| |
| Completer _timeoutCompleter(int timeout) { |
| final completer = Completer(); |
| Timer(Duration(milliseconds: timeout), () { |
| completer.complete(); |
| }); |
| return completer; |
| } |
| |
| Completer<HoverCardData?> _hoverCardDataCompleter( |
| Future<HoverCardData?> hoverCardDataFuture, |
| ) { |
| final completer = Completer<HoverCardData?>(); |
| unawaited( |
| hoverCardDataFuture.then( |
| (data) => completer.complete(data), |
| onError: (_) => completer.complete(null), |
| ), |
| ); |
| return completer; |
| } |
| |
| Offset _calculateTooltipPosition(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 offset = box.localToGlobal( |
| box.size.bottomCenter(Offset.zero).translate(-width / 2, _hoverYOffset), |
| ancestor: overlayBox, |
| ); |
| |
| return Offset( |
| offset.dx.clamp(_hoverMargin, maxX), |
| offset.dy.clamp(_hoverMargin, maxY), |
| ); |
| } |
| |
| @override |
| void dispose() { |
| _showTimer?.cancel(); |
| _removeTimer?.cancel(); |
| if (_currentHoverCard != null) { |
| // If the widget that triggered the hovercard is disposed, then the |
| // HoverCard should be removed from the screen |
| _hoverCardController.removeHoverCard(_currentHoverCard!); |
| } |
| widget.disposable?.dispose(); |
| super.dispose(); |
| } |
| |
| @override |
| void didChangeDependencies() { |
| super.didChangeDependencies(); |
| _hoverCardController = Provider.of<HoverCardController>(context); |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| return MouseRegion( |
| onExit: (_) => _onHoverExit(), |
| onHover: _onHover, |
| child: widget.child, |
| ); |
| } |
| } |