blob: a450052411526c4c459b0cf8a7438810e70ee244 [file] [log] [blame]
// 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 '../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,
String? title,
double? maxCardHeight,
}) {
maxCardHeight ??= maxHoverCardHeight;
final overlayState = Overlay.of(context)!;
final colorScheme = Theme.of(context).colorScheme;
final focusColor = Theme.of(context).focusColor;
final hoverHeading = colorScheme.hoverTitleTextStyle;
_overlayEntry = OverlayEntry(
builder: (context) {
return Positioned(
left: position.dx,
top: position.dy,
child: MouseRegion(
onExit: (_) {
remove();
},
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: colorScheme.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,
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,
);
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.
void maybeRemove() {
if (!_hasMouseEntered) remove();
}
/// Removes the HoverCard even if the mouse is in the corresponding mouse
/// region.
void remove() {
if (!_isRemoved) _overlayEntry.remove();
_isRemoved = true;
}
}
/// A hover card based tooltip.
class HoverCardTooltip extends StatefulWidget {
const HoverCardTooltip({
required this.enabled,
required this.onHover,
required this.child,
this.disposable,
});
static const _hoverDelay = Duration(milliseconds: 500);
static double get defaultHoverWidth => scaleByFontFactor(450.0);
/// Whether the tooltip is currently enabled.
final bool Function() enabled;
/// Data to display when hovering over a particular point.
final Future<HoverCardData> Function(PointerHoverEvent event) onHover;
final Widget child;
/// Disposable object to be disposed when the group is closed.
final Disposable? disposable;
@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;
/// Displays the evaluation result of a source code item.
HoverCard? _hoverCard;
void _onHoverExit() {
_showTimer?.cancel();
_removeTimer = Timer(HoverCardTooltip._hoverDelay, () {
_hoverCard?.maybeRemove();
});
}
void _onHover(PointerHoverEvent event) {
_showTimer?.cancel();
_showTimer = null;
_removeTimer?.cancel();
_removeTimer = null;
if (!widget.enabled()) return;
_showTimer = Timer(HoverCardTooltip._hoverDelay, () async {
_hoverCard?.remove();
_hoverCard = null;
final hoverCardData = await widget.onHover(event);
if (!mounted) return;
if (hoverCardData.position == HoverCardPosition.cursor) {
_hoverCard = HoverCard.fromHoverEvent(
context: context,
title: hoverCardData.title,
contents: hoverCardData.contents,
width: hoverCardData.width,
event: event,
);
} else {
_hoverCard = HoverCard(
context: context,
title: hoverCardData.title,
contents: hoverCardData.contents,
width: hoverCardData.width,
position: _calculateTooltipPosition(hoverCardData.width),
);
}
});
}
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();
_hoverCard?.remove();
widget.disposable?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MouseRegion(
onExit: (_) => _onHoverExit(),
onHover: _onHover,
child: widget.child,
);
}
}