| // Copyright 2020 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/foundation.dart'; |
| import 'package:flutter/gestures.dart'; |
| import 'package:flutter/material.dart'; |
| import 'package:flutter/services.dart'; |
| import 'package:provider/provider.dart'; |
| import 'package:vm_service/vm_service.dart' hide Stack; |
| |
| import '../auto_dispose_mixin.dart'; |
| import '../common_widgets.dart'; |
| import '../config_specific/logger/logger.dart'; |
| import '../dialogs.dart'; |
| import '../flutter_widgets/linked_scroll_controller.dart'; |
| import '../globals.dart'; |
| import '../history_viewport.dart'; |
| import '../theme.dart'; |
| import '../ui/colors.dart'; |
| import '../ui/search.dart'; |
| import '../ui/utils.dart'; |
| import '../utils.dart'; |
| import 'breakpoints.dart'; |
| import 'common.dart'; |
| import 'debugger_controller.dart'; |
| import 'debugger_model.dart'; |
| import 'hover.dart'; |
| import 'variables.dart'; |
| |
| final debuggerCodeViewSearchKey = |
| GlobalKey(debugLabel: 'DebuggerCodeViewSearchKey'); |
| |
| // TODO(kenz): consider moving lines / pausedPositions calculations to the |
| // controller. |
| class CodeView extends StatefulWidget { |
| const CodeView({ |
| Key key, |
| this.controller, |
| this.scriptRef, |
| this.parsedScript, |
| this.onSelected, |
| }) : super(key: key); |
| |
| static const rowHeight = 20.0; |
| static const assumedCharacterWidth = 16.0; |
| |
| final DebuggerController controller; |
| final ScriptRef scriptRef; |
| final ParsedScript parsedScript; |
| |
| final void Function(ScriptRef scriptRef, int line) onSelected; |
| |
| @override |
| _CodeViewState createState() => _CodeViewState(); |
| } |
| |
| class _CodeViewState extends State<CodeView> |
| with AutoDisposeMixin, SearchFieldMixin<CodeView> { |
| static const searchFieldRightPadding = 75.0; |
| |
| LinkedScrollControllerGroup verticalController; |
| ScrollController gutterController; |
| ScrollController textController; |
| |
| ScriptRef get scriptRef => widget.scriptRef; |
| |
| ParsedScript get parsedScript => widget.parsedScript; |
| |
| @override |
| void initState() { |
| super.initState(); |
| |
| verticalController = LinkedScrollControllerGroup(); |
| gutterController = verticalController.addAndGet(); |
| textController = verticalController.addAndGet(); |
| |
| addAutoDisposeListener( |
| widget.controller.scriptLocation, |
| _handleScriptLocationChanged, |
| ); |
| } |
| |
| @override |
| void didUpdateWidget(CodeView oldWidget) { |
| super.didUpdateWidget(oldWidget); |
| |
| if (widget.controller != oldWidget.controller) { |
| cancel(); |
| |
| addAutoDisposeListener( |
| widget.controller.scriptLocation, _handleScriptLocationChanged); |
| } |
| } |
| |
| @override |
| void dispose() { |
| gutterController.dispose(); |
| textController.dispose(); |
| widget.controller.scriptLocation |
| .removeListener(_handleScriptLocationChanged); |
| super.dispose(); |
| } |
| |
| void _handleScriptLocationChanged() { |
| if (mounted) { |
| _updateScrollPosition(); |
| } |
| } |
| |
| void _updateScrollPosition({bool animate = true}) { |
| if (widget.controller.scriptLocation.value?.scriptRef != scriptRef) { |
| return; |
| } |
| |
| final location = widget.controller.scriptLocation.value?.location; |
| if (location?.line == null) { |
| return; |
| } |
| |
| if (!verticalController.hasAttachedControllers) { |
| // TODO(devoncarew): I'm uncertain why this occurs. |
| log('LinkedScrollControllerGroup has no attached controllers'); |
| return; |
| } |
| |
| final position = verticalController.position; |
| final extent = position.extentInside; |
| |
| // TODO(devoncarew): Adjust this so we don't scroll if we're already in the |
| // middle third of the screen. |
| if (parsedScript.lineCount * CodeView.rowHeight > extent) { |
| // Scroll to the middle of the screen. |
| final lineIndex = location.line - 1; |
| final scrollPosition = |
| lineIndex * CodeView.rowHeight - (extent - CodeView.rowHeight) / 2; |
| if (animate) { |
| verticalController.animateTo( |
| scrollPosition, |
| duration: longDuration, |
| curve: defaultCurve, |
| ); |
| } else { |
| verticalController.jumpTo(scrollPosition); |
| } |
| } |
| } |
| |
| void _onPressed(int line) { |
| widget.onSelected(scriptRef, line); |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| final theme = Theme.of(context); |
| |
| if (scriptRef == null) { |
| return Center( |
| child: Text( |
| 'No script selected', |
| style: theme.textTheme.subtitle1, |
| ), |
| ); |
| } |
| |
| if (parsedScript == null) { |
| return const CenteredCircularProgressIndicator(); |
| } |
| |
| return ValueListenableBuilder( |
| valueListenable: widget.controller.showSearchInFileField, |
| builder: (context, showSearch, _) { |
| return Stack( |
| children: [ |
| buildCodeArea(context), |
| if (showSearch) |
| Positioned( |
| top: denseSpacing, |
| right: searchFieldRightPadding, |
| child: buildSearchInFileField(), |
| ), |
| ], |
| ); |
| }, |
| ); |
| } |
| |
| Widget buildCodeArea(BuildContext context) { |
| final theme = Theme.of(context); |
| |
| final lines = <TextSpan>[]; |
| |
| // Ensure the syntax highlighter has been initialized. |
| // TODO(bkonyi): process source for highlighting on a separate thread. |
| if (parsedScript.script.source != null) { |
| if (parsedScript.script.source.length < 500000 && |
| parsedScript.highlighter != null) { |
| final highlighted = parsedScript.highlighter.highlight(context); |
| |
| // Look for [TextSpan]s which only contain '\n' to manually break the |
| // output from the syntax highlighter into individual lines. |
| var currentLine = <TextSpan>[]; |
| highlighted.visitChildren((span) { |
| currentLine.add(span); |
| if (span.toPlainText() == '\n') { |
| lines.add( |
| TextSpan( |
| style: theme.fixedFontStyle, |
| children: currentLine, |
| ), |
| ); |
| currentLine = <TextSpan>[]; |
| } |
| return true; |
| }); |
| lines.add( |
| TextSpan( |
| style: theme.fixedFontStyle, |
| children: currentLine, |
| ), |
| ); |
| } else { |
| lines.addAll( |
| [ |
| for (final line in parsedScript.script.source.split('\n')) |
| TextSpan( |
| style: theme.fixedFontStyle, |
| text: line, |
| ), |
| ], |
| ); |
| } |
| } |
| |
| // Apply the log change-of-base formula, then add 16dp padding for every |
| // digit in the maximum number of lines. |
| final gutterWidth = CodeView.assumedCharacterWidth * 1.5 + |
| CodeView.assumedCharacterWidth * |
| (defaultEpsilon + math.log(math.max(lines.length, 100)) / math.ln10) |
| .truncateToDouble(); |
| |
| _updateScrollPosition(animate: false); |
| |
| return HistoryViewport( |
| history: widget.controller.scriptsHistory, |
| generateTitle: (script) => script.uri, |
| controls: [ |
| ScriptPopupMenu(widget.controller), |
| ScriptHistoryPopupMenu( |
| itemBuilder: _buildScriptMenuFromHistory, |
| onSelected: (scriptRef) { |
| widget.controller.showScriptLocation(ScriptLocation(scriptRef)); |
| }, |
| enabled: widget.controller.scriptsHistory.hasScripts, |
| ), |
| ], |
| contentBuilder: (context, script) { |
| if (lines.isNotEmpty) { |
| return DefaultTextStyle( |
| style: theme.fixedFontStyle, |
| child: Expanded( |
| child: Scrollbar( |
| controller: textController, |
| child: ValueListenableBuilder<StackFrameAndSourcePosition>( |
| valueListenable: widget.controller.selectedStackFrame, |
| builder: (context, frame, _) { |
| final pausedFrame = frame == null |
| ? null |
| : (frame.scriptRef == scriptRef ? frame : null); |
| |
| return Row( |
| children: [ |
| ValueListenableBuilder< |
| List<BreakpointAndSourcePosition>>( |
| valueListenable: |
| widget.controller.breakpointsWithLocation, |
| builder: (context, breakpoints, _) { |
| return Gutter( |
| gutterWidth: gutterWidth, |
| scrollController: gutterController, |
| lineCount: lines.length, |
| pausedFrame: pausedFrame, |
| breakpoints: breakpoints |
| .where((bp) => bp.scriptRef == scriptRef) |
| .toList(), |
| executableLines: parsedScript.executableLines, |
| onPressed: _onPressed, |
| // Disable dots for possible breakpoint locations. |
| allowInteraction: |
| !widget.controller.isSystemIsolate, |
| ); |
| }, |
| ), |
| const SizedBox(width: denseSpacing), |
| Expanded( |
| child: LayoutBuilder( |
| builder: (context, constraints) { |
| return Lines( |
| constraints: constraints, |
| scrollController: textController, |
| lines: lines, |
| pausedFrame: pausedFrame, |
| searchMatchesNotifier: |
| widget.controller.searchMatches, |
| activeSearchMatchNotifier: |
| widget.controller.activeSearchMatch, |
| ); |
| }, |
| ), |
| ), |
| ], |
| ); |
| }, |
| ), |
| ), |
| ), |
| ); |
| } else { |
| return Expanded( |
| child: Center( |
| child: Text( |
| 'No source available', |
| style: theme.textTheme.subtitle1, |
| ), |
| ), |
| ); |
| } |
| }, |
| ); |
| } |
| |
| Widget buildSearchInFileField() { |
| return Card( |
| elevation: defaultElevation, |
| color: Theme.of(context).scaffoldBackgroundColor, |
| shape: RoundedRectangleBorder( |
| borderRadius: BorderRadius.circular(defaultBorderRadius), |
| ), |
| child: Container( |
| width: wideSearchTextWidth, |
| height: defaultTextFieldHeight + 2 * denseSpacing, |
| padding: const EdgeInsets.all(denseSpacing), |
| child: buildSearchField( |
| controller: widget.controller, |
| searchFieldKey: debuggerCodeViewSearchKey, |
| searchFieldEnabled: parsedScript != null, |
| shouldRequestFocus: true, |
| supportsNavigation: true, |
| onClose: () => widget.controller.toggleSearchInFileVisibility(false), |
| ), |
| ), |
| ); |
| } |
| |
| List<PopupMenuEntry<ScriptRef>> _buildScriptMenuFromHistory( |
| BuildContext context, |
| ) { |
| const scriptHistorySize = 16; |
| |
| return widget.controller.scriptsHistory.openedScripts |
| .take(scriptHistorySize) |
| .map((scriptRef) { |
| return PopupMenuItem( |
| value: scriptRef, |
| child: Column( |
| mainAxisAlignment: MainAxisAlignment.center, |
| crossAxisAlignment: CrossAxisAlignment.start, |
| children: [ |
| Text( |
| ScriptRefUtils.fileName(scriptRef), |
| maxLines: 1, |
| overflow: TextOverflow.ellipsis, |
| ), |
| Text( |
| scriptRef.uri, |
| overflow: TextOverflow.ellipsis, |
| maxLines: 1, |
| style: Theme.of(context).subtleTextStyle, |
| ), |
| ], |
| ), |
| ); |
| }).toList(); |
| } |
| } |
| |
| typedef IntCallback = void Function(int value); |
| |
| class Gutter extends StatelessWidget { |
| const Gutter({ |
| @required this.gutterWidth, |
| @required this.scrollController, |
| @required this.lineCount, |
| @required this.pausedFrame, |
| @required this.breakpoints, |
| @required this.executableLines, |
| @required this.onPressed, |
| @required this.allowInteraction, |
| }); |
| |
| final double gutterWidth; |
| final ScrollController scrollController; |
| final int lineCount; |
| final StackFrameAndSourcePosition pausedFrame; |
| final List<BreakpointAndSourcePosition> breakpoints; |
| final Set<int> executableLines; |
| final IntCallback onPressed; |
| final bool allowInteraction; |
| |
| @override |
| Widget build(BuildContext context) { |
| final bpLineSet = Set.from(breakpoints.map((bp) => bp.line)); |
| |
| return Container( |
| width: gutterWidth, |
| color: titleSolidBackgroundColor(Theme.of(context)), |
| child: ListView.builder( |
| controller: scrollController, |
| itemExtent: CodeView.rowHeight, |
| itemCount: lineCount, |
| itemBuilder: (context, index) { |
| final lineNum = index + 1; |
| return GutterItem( |
| lineNumber: lineNum, |
| onPressed: () => onPressed(lineNum), |
| isBreakpoint: bpLineSet.contains(lineNum), |
| isExecutable: executableLines.contains(lineNum), |
| isPausedHere: pausedFrame?.line == lineNum, |
| allowInteraction: allowInteraction, |
| ); |
| }, |
| ), |
| ); |
| } |
| } |
| |
| class GutterItem extends StatelessWidget { |
| const GutterItem({ |
| Key key, |
| @required this.lineNumber, |
| @required this.isBreakpoint, |
| @required this.isExecutable, |
| @required this.isPausedHere, |
| @required this.onPressed, |
| @required this.allowInteraction, |
| }) : super(key: key); |
| |
| final int lineNumber; |
| |
| final bool isBreakpoint; |
| |
| final bool isExecutable; |
| |
| final bool allowInteraction; |
| |
| /// Whether the execution point is currently paused here. |
| final bool isPausedHere; |
| |
| final VoidCallback onPressed; |
| |
| @override |
| Widget build(BuildContext context) { |
| final theme = Theme.of(context); |
| |
| final foregroundColor = theme.isDarkTheme |
| ? theme.textTheme.bodyText2.color |
| : theme.primaryColor; |
| final subtleColor = theme.unselectedWidgetColor; |
| |
| const bpBoxSize = 12.0; |
| const executionPointIndent = 10.0; |
| |
| return InkWell( |
| onTap: onPressed, |
| // Force usage of default mouse pointer when gutter interaction is |
| // disabled. |
| mouseCursor: allowInteraction ? null : SystemMouseCursors.basic, |
| child: Container( |
| height: CodeView.rowHeight, |
| padding: const EdgeInsets.only(right: 4.0), |
| child: Stack( |
| alignment: AlignmentDirectional.centerStart, |
| fit: StackFit.expand, |
| children: [ |
| if (allowInteraction && (isExecutable || isBreakpoint)) |
| Align( |
| alignment: Alignment.centerLeft, |
| child: SizedBox( |
| width: bpBoxSize, |
| height: bpBoxSize, |
| child: Center( |
| child: createAnimatedCircleWidget( |
| isBreakpoint ? breakpointRadius : executableLineRadius, |
| isBreakpoint ? foregroundColor : subtleColor, |
| ), |
| ), |
| ), |
| ), |
| Text('$lineNumber', textAlign: TextAlign.end), |
| Container( |
| padding: const EdgeInsets.only(left: executionPointIndent), |
| alignment: Alignment.centerLeft, |
| child: AnimatedOpacity( |
| duration: defaultDuration, |
| curve: defaultCurve, |
| opacity: isPausedHere ? 1.0 : 0.0, |
| child: Icon( |
| Icons.label, |
| size: defaultIconSize, |
| color: foregroundColor, |
| ), |
| ), |
| ), |
| ], |
| ), |
| ), |
| ); |
| } |
| } |
| |
| class Lines extends StatefulWidget { |
| const Lines({ |
| Key key, |
| @required this.constraints, |
| @required this.scrollController, |
| @required this.lines, |
| @required this.pausedFrame, |
| @required this.searchMatchesNotifier, |
| @required this.activeSearchMatchNotifier, |
| }) : super(key: key); |
| |
| final BoxConstraints constraints; |
| final ScrollController scrollController; |
| final List<TextSpan> lines; |
| final StackFrameAndSourcePosition pausedFrame; |
| final ValueListenable<List<SourceToken>> searchMatchesNotifier; |
| final ValueListenable<SourceToken> activeSearchMatchNotifier; |
| |
| @override |
| _LinesState createState() => _LinesState(); |
| } |
| |
| class _LinesState extends State<Lines> with AutoDisposeMixin { |
| List<SourceToken> searchMatches; |
| |
| SourceToken activeSearch; |
| |
| @override |
| void initState() { |
| super.initState(); |
| |
| cancel(); |
| searchMatches = widget.searchMatchesNotifier.value; |
| addAutoDisposeListener(widget.searchMatchesNotifier, () { |
| setState(() { |
| searchMatches = widget.searchMatchesNotifier.value; |
| }); |
| }); |
| |
| activeSearch = widget.activeSearchMatchNotifier.value; |
| addAutoDisposeListener(widget.activeSearchMatchNotifier, () { |
| setState(() { |
| activeSearch = widget.activeSearchMatchNotifier.value; |
| }); |
| |
| if (activeSearch != null) { |
| final isOutOfViewTop = activeSearch.position.line * CodeView.rowHeight < |
| widget.scrollController.offset + CodeView.rowHeight; |
| final isOutOfViewBottom = |
| activeSearch.position.line * CodeView.rowHeight > |
| widget.scrollController.offset + |
| widget.constraints.maxHeight - |
| CodeView.rowHeight; |
| |
| if (isOutOfViewTop || isOutOfViewBottom) { |
| // Scroll this search token to the middle of the view. |
| final targetOffset = math.max( |
| activeSearch.position.line * CodeView.rowHeight - |
| widget.constraints.maxHeight / 2, |
| 0.0, |
| ); |
| widget.scrollController.animateTo( |
| targetOffset, |
| duration: defaultDuration, |
| curve: defaultCurve, |
| ); |
| } |
| } |
| }); |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| final pausedLine = widget.pausedFrame?.line; |
| |
| return ListView.builder( |
| controller: widget.scrollController, |
| itemExtent: CodeView.rowHeight, |
| itemCount: widget.lines.length, |
| itemBuilder: (context, index) { |
| final lineNum = index + 1; |
| return LineItem( |
| lineContents: widget.lines[index], |
| pausedFrame: pausedLine == lineNum ? widget.pausedFrame : null, |
| searchMatches: searchMatchesForLine(index), |
| activeSearchMatch: |
| activeSearch?.position?.line == index ? activeSearch : null, |
| ); |
| }, |
| ); |
| } |
| |
| List<SourceToken> searchMatchesForLine(int index) { |
| return searchMatches |
| .where((searchToken) => searchToken.position.line == index) |
| .toList(); |
| } |
| } |
| |
| class LineItem extends StatefulWidget { |
| const LineItem({ |
| Key key, |
| @required this.lineContents, |
| this.pausedFrame, |
| this.searchMatches, |
| this.activeSearchMatch, |
| }) : super(key: key); |
| |
| static const _hoverDelay = Duration(milliseconds: 50); |
| static const _hoverWidth = 400.0; |
| |
| final TextSpan lineContents; |
| final StackFrameAndSourcePosition pausedFrame; |
| final List<SourceToken> searchMatches; |
| final SourceToken activeSearchMatch; |
| |
| @override |
| _LineItemState createState() => _LineItemState(); |
| } |
| |
| class _LineItemState extends State<LineItem> { |
| /// 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; |
| |
| DebuggerController _debuggerController; |
| |
| String _previousHoverWord = ''; |
| |
| void _onHoverExit() { |
| _showTimer?.cancel(); |
| _removeTimer = Timer(LineItem._hoverDelay, () { |
| _hoverCard?.maybeRemove(); |
| _previousHoverWord = ''; |
| }); |
| } |
| |
| void _onHover(PointerHoverEvent event, BuildContext context) { |
| _showTimer?.cancel(); |
| _removeTimer?.cancel(); |
| if (!_debuggerController.isPaused.value) return; |
| _showTimer = Timer(LineItem._hoverDelay, () async { |
| final word = wordForHover( |
| event.localPosition.dx, |
| widget.lineContents, |
| ); |
| if (word == _previousHoverWord) return; |
| _previousHoverWord = word; |
| _hoverCard?.remove(); |
| if (word != '') { |
| try { |
| final response = await _debuggerController.evalAtCurrentFrame(word); |
| if (response is! InstanceRef) return; |
| final variable = Variable.fromRef(response); |
| await _debuggerController.buildVariablesTree(variable); |
| _hoverCard = HoverCard( |
| contents: Material( |
| child: ExpandableVariable( |
| debuggerController: _debuggerController, |
| variable: ValueNotifier(variable), |
| ), |
| ), |
| event: event, |
| width: LineItem._hoverWidth, |
| title: word, |
| context: context, |
| ); |
| } catch (_) { |
| // Silently fail and don't display a HoverCard. |
| } |
| } |
| }); |
| } |
| |
| @override |
| void dispose() { |
| _showTimer?.cancel(); |
| _removeTimer?.cancel(); |
| _hoverCard?.remove(); |
| super.dispose(); |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| final theme = Theme.of(context); |
| final darkTheme = theme.brightness == Brightness.dark; |
| _debuggerController = Provider.of<DebuggerController>(context); |
| |
| Widget child; |
| if (widget.pausedFrame != null) { |
| final column = widget.pausedFrame.column; |
| |
| final foregroundColor = |
| darkTheme ? theme.textTheme.bodyText2.color : theme.primaryColor; |
| |
| // The following constants are tweaked for using the |
| // 'Icons.label_important' icon. |
| const colIconSize = 13.0; |
| const colLeftOffset = -3.0; |
| const colBottomOffset = 13.0; |
| const colIconRotate = -90 * math.pi / 180; |
| |
| // TODO: support selecting text across multiples lines. |
| child = Stack( |
| children: [ |
| Row( |
| children: [ |
| // Create a hidden copy of the first column-1 characters of the |
| // line as a hack to correctly compute where to place |
| // the cursor. Approximating by using column-1 spaces instead |
| // of the correct characters and styles would be risky as it leads |
| // to small errors if the font is not fixed size or the font |
| // styles vary depending on the syntax highlighting. |
| // TODO(jacobr): there might be some api exposed on SelectedText |
| // to allow us to render this as a proper overlay as similar |
| // functionality exists to render the selection handles properly. |
| Opacity( |
| opacity: 0, |
| child: RichText( |
| text: truncateTextSpan(widget.lineContents, column - 1), |
| ), |
| ), |
| Transform.translate( |
| offset: const Offset(colLeftOffset, colBottomOffset), |
| child: Transform.rotate( |
| angle: colIconRotate, |
| child: Icon( |
| Icons.label_important, |
| size: colIconSize, |
| color: foregroundColor, |
| ), |
| ), |
| ) |
| ], |
| ), |
| _hoverableLine(), |
| ], |
| ); |
| } else { |
| child = _hoverableLine(); |
| } |
| |
| final backgroundColor = widget.pausedFrame != null |
| ? (darkTheme |
| ? theme.canvasColor.brighten() |
| : theme.canvasColor.darken()) |
| : null; |
| |
| return Container( |
| alignment: Alignment.centerLeft, |
| height: CodeView.rowHeight, |
| color: backgroundColor, |
| child: child, |
| ); |
| } |
| |
| TextSpan searchAwareLineContents() { |
| final activeSearchAwareContents = |
| _activeSearchAwareLineContents(widget.lineContents.children); |
| final allSearchAwareContents = |
| _searchMatchAwareLineContents(activeSearchAwareContents); |
| return TextSpan( |
| children: allSearchAwareContents, |
| style: widget.lineContents.style, |
| ); |
| } |
| |
| List<TextSpan> _contentsWithMatch( |
| List<TextSpan> startingContents, |
| SourceToken match, |
| Color matchColor, |
| ) { |
| final contentsWithMatch = <TextSpan>[]; |
| var startColumnForSpan = 0; |
| for (final span in startingContents) { |
| final spanText = span.toPlainText(); |
| final startColumnForMatch = match.position.column; |
| if (startColumnForSpan <= startColumnForMatch && |
| startColumnForSpan + spanText.length > startColumnForMatch) { |
| // The active search is part of this [span]. |
| final matchStartInSpan = startColumnForMatch - startColumnForSpan; |
| final matchEndInSpan = matchStartInSpan + match.length; |
| |
| // Add the part of [span] that occurs before the search match. |
| contentsWithMatch.add( |
| TextSpan( |
| text: spanText.substring(0, matchStartInSpan), |
| style: span.style, |
| ), |
| ); |
| |
| final matchStyle = |
| (span.style ?? DefaultTextStyle.of(context).style).copyWith( |
| color: Colors.black, |
| backgroundColor: matchColor, |
| ); |
| |
| if (matchEndInSpan <= spanText.length) { |
| final matchText = |
| spanText.substring(matchStartInSpan, matchEndInSpan); |
| final trailingText = spanText.substring(matchEndInSpan); |
| // Add the match and any part of [span] that occurs after the search |
| // match. |
| contentsWithMatch.addAll([ |
| TextSpan( |
| text: matchText, |
| style: matchStyle, |
| ), |
| if (trailingText.isNotEmpty) |
| TextSpan( |
| text: spanText.substring(matchEndInSpan), |
| style: span.style, |
| ), |
| ]); |
| } else { |
| // In this case, the active search match exists across multiple spans, |
| // so we need to add the part of the match that is in this [span] and |
| // continue looking for the remaining part of the match in the spans |
| // to follow. |
| contentsWithMatch.add( |
| TextSpan( |
| text: spanText.substring(matchStartInSpan), |
| style: matchStyle, |
| ), |
| ); |
| final remainingMatchLength = |
| match.length - (spanText.length - matchStartInSpan); |
| match = SourceToken( |
| position: SourcePosition( |
| line: match.position.line, |
| column: startColumnForMatch + match.length - remainingMatchLength, |
| ), |
| length: remainingMatchLength, |
| ); |
| } |
| } else { |
| contentsWithMatch.add(span); |
| } |
| startColumnForSpan += spanText.length; |
| } |
| return contentsWithMatch; |
| } |
| |
| List<TextSpan> _activeSearchAwareLineContents( |
| List<TextSpan> startingContents, |
| ) { |
| if (widget.activeSearchMatch == null) return startingContents; |
| return _contentsWithMatch( |
| startingContents, |
| widget.activeSearchMatch, |
| activeSearchMatchColor, |
| ); |
| } |
| |
| List<TextSpan> _searchMatchAwareLineContents( |
| List<TextSpan> startingContents, |
| ) { |
| if (widget.searchMatches.isEmpty) return startingContents; |
| final searchMatchesToFind = List<SourceToken>.from(widget.searchMatches) |
| ..remove(widget.activeSearchMatch); |
| |
| var contentsWithMatch = startingContents; |
| for (final match in searchMatchesToFind) { |
| contentsWithMatch = _contentsWithMatch( |
| contentsWithMatch, |
| match, |
| searchMatchColor, |
| ); |
| } |
| return contentsWithMatch; |
| } |
| |
| Widget _hoverableLine() => MouseRegion( |
| onExit: (_) => _onHoverExit(), |
| onHover: (e) => _onHover(e, context), |
| child: SelectableText.rich( |
| searchAwareLineContents(), |
| scrollPhysics: const NeverScrollableScrollPhysics(), |
| maxLines: 1, |
| ), |
| ); |
| } |
| |
| class ScriptPopupMenu extends StatelessWidget { |
| const ScriptPopupMenu(this._controller); |
| |
| final DebuggerController _controller; |
| |
| @override |
| Widget build(BuildContext context) { |
| return PopupMenuButton<ScriptPopupMenuOption>( |
| onSelected: (option) => option.onSelected(context, _controller), |
| itemBuilder: (_) => [ |
| for (final menuOption in defaultScriptPopupMenuOptions) |
| menuOption.build(context), |
| for (final extensionMenuOption in devToolsExtensionPoints |
| .buildExtraDebuggerScriptPopupMenuOptions()) |
| extensionMenuOption.build(context), |
| ], |
| child: const Icon( |
| Icons.more_vert, |
| size: actionsIconSize, |
| ), |
| ); |
| } |
| } |
| |
| class ScriptHistoryPopupMenu extends StatelessWidget { |
| const ScriptHistoryPopupMenu({ |
| @required this.itemBuilder, |
| @required this.onSelected, |
| @required this.enabled, |
| }); |
| |
| final PopupMenuItemBuilder<ScriptRef> itemBuilder; |
| |
| final void Function(ScriptRef) onSelected; |
| |
| final bool enabled; |
| |
| @override |
| Widget build(BuildContext context) { |
| return PopupMenuButton<ScriptRef>( |
| itemBuilder: itemBuilder, |
| tooltip: 'Select recent script', |
| enabled: enabled, |
| onSelected: onSelected, |
| offset: const Offset( |
| actionsIconSize + denseSpacing, |
| buttonMinWidth + denseSpacing, |
| ), |
| child: const Icon( |
| Icons.history, |
| size: actionsIconSize, |
| ), |
| ); |
| } |
| } |
| |
| class ScriptPopupMenuOption { |
| const ScriptPopupMenuOption({ |
| @required this.label, |
| @required this.onSelected, |
| this.icon, |
| }); |
| |
| final String label; |
| |
| final void Function(BuildContext, DebuggerController) onSelected; |
| |
| final IconData icon; |
| |
| PopupMenuItem<ScriptPopupMenuOption> build(BuildContext context) { |
| return PopupMenuItem<ScriptPopupMenuOption>( |
| value: this, |
| child: Row( |
| mainAxisAlignment: MainAxisAlignment.spaceBetween, |
| children: [ |
| Text(label, style: Theme.of(context).regularTextStyle), |
| if (icon != null) |
| Icon( |
| icon, |
| size: actionsIconSize, |
| ), |
| ], |
| ), |
| ); |
| } |
| } |
| |
| final defaultScriptPopupMenuOptions = [copyScriptNameOption, goToLineOption]; |
| |
| final copyScriptNameOption = ScriptPopupMenuOption( |
| label: 'Copy filename', |
| icon: Icons.content_copy, |
| onSelected: (_, controller) => Clipboard.setData( |
| ClipboardData(text: controller.scriptLocation.value?.scriptRef?.uri), |
| ), |
| ); |
| |
| void showGoToLineDialog(BuildContext context, DebuggerController controller) { |
| showDialog( |
| context: context, |
| builder: (context) => GoToLineDialog(controller), |
| ); |
| } |
| |
| const goToLineOption = ScriptPopupMenuOption( |
| label: 'Go to line number', |
| icon: Icons.list, |
| onSelected: showGoToLineDialog, |
| ); |
| |
| class GoToLineDialog extends StatelessWidget { |
| const GoToLineDialog(this._debuggerController); |
| |
| final DebuggerController _debuggerController; |
| |
| @override |
| Widget build(BuildContext context) { |
| return DevToolsDialog( |
| title: dialogTitleText(Theme.of(context), 'Go To'), |
| content: Column( |
| mainAxisSize: MainAxisSize.min, |
| crossAxisAlignment: CrossAxisAlignment.start, |
| children: [ |
| TextField( |
| autofocus: true, |
| onSubmitted: (value) { |
| if (value.isNotEmpty) { |
| Navigator.of(context).pop(dialogDefaultContext); |
| final line = int.parse(value); |
| _debuggerController.showScriptLocation( |
| ScriptLocation( |
| _debuggerController.scriptLocation.value.scriptRef, |
| location: SourcePosition(line: line, column: 0), |
| ), |
| ); |
| } |
| }, |
| decoration: const InputDecoration(labelText: 'Line Number'), |
| keyboardType: TextInputType.number, |
| inputFormatters: <TextInputFormatter>[ |
| FilteringTextInputFormatter.digitsOnly |
| ], |
| ) |
| ], |
| ), |
| actions: const [ |
| DialogCancelButton(), |
| ], |
| ); |
| } |
| } |