Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/devtools_app/lib/devtools_app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export 'src/screens/inspector/inspector_screen_body.dart';
export 'src/screens/inspector/inspector_tree_controller.dart';
export 'src/screens/inspector_shared/inspector_screen.dart';
export 'src/screens/inspector_shared/inspector_screen_controller.dart';
export 'src/screens/logging/log_details_controller.dart';
export 'src/screens/logging/logging_controller.dart';
export 'src/screens/logging/logging_screen.dart';
export 'src/screens/memory/framework/memory_controller.dart';
Expand Down
126 changes: 9 additions & 117 deletions packages/devtools_app/lib/src/screens/debugger/codeview.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import '../../shared/ui/common_widgets.dart';
import '../../shared/ui/history_viewport.dart';
import '../../shared/ui/hover.dart';
import '../../shared/ui/search.dart';
import '../../shared/ui/search_highlighter.dart';
import '../../shared/ui/utils.dart';
import '../vm_developer/vm_service_private_extensions.dart';
import 'breakpoints.dart';
Expand Down Expand Up @@ -1272,131 +1273,22 @@ class _HoverableLine extends StatelessWidget {
return null;
}

List<InlineSpan> _contentsWithMatch(
List<InlineSpan> startingContents,
SourceToken match,
Color matchColor, {
required BuildContext context,
}) {
final contentsWithMatch = <InlineSpan>[];
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;
}

TextSpan searchAwareLineContents(BuildContext context) {
// If syntax highlighting is disabled for the script, then
// `lineContents` is simply a `TextSpan` with no children.
final lineContentsSpans = lineContents.children ?? [lineContents];
final activeSearchAwareContents = _activeSearchAwareLineContents(
lineContentsSpans,
context: context,
);
final allSearchAwareContents = _searchMatchAwareLineContents(
activeSearchAwareContents!,
context: context,
);
final theme = Theme.of(context);

return TextSpan(
children: allSearchAwareContents,
children: SearchHighlighter.highlightSpans(
lineContentsSpans.cast<TextSpan>(),
matches: searchMatches?.map((m) => m.range).toList() ?? [],
activeMatch: activeSearchMatch?.range,
style: theme.regularTextStyle,
),
style: lineContents.style,
);
}

List<InlineSpan>? _activeSearchAwareLineContents(
List<InlineSpan> startingContents, {
required BuildContext context,
}) {
final match = activeSearchMatch;
if (match == null) return startingContents;
return _contentsWithMatch(
startingContents,
match,
activeSearchMatchColor,
context: context,
);
}

List<InlineSpan> _searchMatchAwareLineContents(
List<InlineSpan> startingContents, {
required BuildContext context,
}) {
final matches = searchMatches;
if (matches == null || matches.isEmpty) return startingContents;
final searchMatchesToFind = List<SourceToken>.of(matches)
..remove(activeSearchMatch);

var contentsWithMatch = startingContents;
for (final match in searchMatchesToFind) {
contentsWithMatch = _contentsWithMatch(
contentsWithMatch,
match,
searchMatchColor,
context: context,
);
}
return contentsWithMatch;
}
}

class ScriptPopupMenu extends StatelessWidget {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ class SourceToken with SearchableDataMixin {

final int length;

Range get range => Range(position.column!, position.column! + length);

@override
String toString() {
return '$position-${position.column! + length}';
Expand Down
132 changes: 127 additions & 5 deletions packages/devtools_app/lib/src/screens/logging/_log_details.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,16 @@ import 'package:flutter/material.dart';
import '../../shared/globals.dart';
import '../../shared/preferences/preferences.dart';
import '../../shared/ui/common_widgets.dart';
import '../../shared/ui/search.dart';
import '../../shared/ui/search_highlighter.dart';
import 'log_details_controller.dart';
import 'logging_controller.dart';

class LogDetails extends StatefulWidget {
const LogDetails({super.key, required this.log});
const LogDetails({super.key, required this.log, required this.controller});

final LogData? log;
final LogDetailsController controller;

@override
State<LogDetails> createState() => _LogDetailsState();
Expand Down Expand Up @@ -45,6 +49,10 @@ class _LogDetailsState extends State<LogDetails>
if (widget.log != oldWidget.log) {
unawaited(_computeLogDetails());
}
if (widget.controller != oldWidget.controller) {
cancelListeners();
addAutoDisposeListener(preferences.logging.detailsFormat);
}
}

Future<void> _computeLogDetails() async {
Expand Down Expand Up @@ -81,6 +89,7 @@ class _LogDetailsState extends State<LogDetails>
header: _LogDetailsHeader(
log: log,
format: preferences.logging.detailsFormat.value,
controller: widget.controller,
),
child: Scrollbar(
controller: scrollController,
Expand All @@ -93,9 +102,9 @@ class _LogDetailsState extends State<LogDetails>
? Padding(
padding: const EdgeInsets.all(denseSpacing),
child: SelectionArea(
child: Text(
log?.prettyPrinted() ?? '',
textAlign: TextAlign.left,
child: _SearchableLogDetailsText(
text: log?.prettyPrinted() ?? '',
controller: widget.controller,
),
),
)
Expand All @@ -107,10 +116,15 @@ class _LogDetailsState extends State<LogDetails>
}

class _LogDetailsHeader extends StatelessWidget {
const _LogDetailsHeader({required this.log, required this.format});
const _LogDetailsHeader({
required this.log,
required this.format,
required this.controller,
});

final LogData? log;
final LoggingDetailsFormat format;
final LogDetailsController controller;

@override
Widget build(BuildContext context) {
Expand All @@ -122,7 +136,13 @@ class _LogDetailsHeader extends StatelessWidget {
title: const Text('Details'),
includeTopBorder: false,
roundedTopBorder: false,
tall: true,
actions: [
// Only supporting search for the text format now since supporting this
// for the expandable JSON viewer would require a more complicated
// refactor of that shared component.
if (format == LoggingDetailsFormat.text)
_LogDetailsSearchField(controller: controller, log: log),
LogDetailsFormatButton(format: format),
const SizedBox(width: densePadding),
CopyToClipboardControl(
Expand All @@ -134,6 +154,108 @@ class _LogDetailsHeader extends StatelessWidget {
}
}

/// An animated search field for the log details view that toggles between an icon
/// and a full [SearchField].
class _LogDetailsSearchField extends StatefulWidget {
const _LogDetailsSearchField({required this.controller, required this.log});

final LogDetailsController controller;
final LogData? log;

@override
State<_LogDetailsSearchField> createState() => _LogDetailsSearchFieldState();
}

class _LogDetailsSearchFieldState extends State<_LogDetailsSearchField>
with AutoDisposeMixin {
late bool _isExpanded;

@override
void initState() {
super.initState();
_isExpanded = widget.controller.search.isNotEmpty;
addAutoDisposeListener(widget.controller.searchFieldFocusNode, () {
final hasFocus =
widget.controller.searchFieldFocusNode?.hasFocus ?? false;
if (hasFocus != _isExpanded) {
setState(() {
_isExpanded = hasFocus;
});
}
});
}

@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: defaultDuration,
curve: defaultCurve,
width: _isExpanded ? mediumSearchFieldWidth : defaultButtonHeight,
child: OverflowBox(
minWidth: 0.0,
maxWidth: mediumSearchFieldWidth,
child: _isExpanded
? Padding(
padding: const EdgeInsets.symmetric(horizontal: densePadding),
child: SearchField<LogDetailsController>(
searchController: widget.controller,
searchFieldEnabled:
widget.log != null && widget.log!.details != null,
shouldRequestFocus: true,
searchFieldWidth: mediumSearchFieldWidth,
),
)
: ToolbarAction(
icon: Icons.search,
tooltip: 'Search details',
size: defaultIconSize,
onPressed: () {
setState(() {
_isExpanded = true;
});
widget.controller.searchFieldFocusNode?.requestFocus();
},
),
),
);
}
}

/// A text widget for the log details view that highlights search matches.
class _SearchableLogDetailsText extends StatelessWidget {
const _SearchableLogDetailsText({
required this.text,
required this.controller,
});

final String text;
final LogDetailsController controller;

@override
Widget build(BuildContext context) {
return MultiValueListenableBuilder(
listenables: [controller.searchMatches, controller.activeSearchMatch],
builder: (context, values, _) {
final theme = Theme.of(context);

final matches = (values[0] as List<LogDetailsMatch>)
.map((m) => m.range)
.toList();
final activeMatch = (values[1] as LogDetailsMatch?)?.range;

return Text.rich(
SearchHighlighter.highlight(
text,
matches,
activeMatch: activeMatch,
style: theme.regularTextStyle,
),
);
},
);
}
}

@visibleForTesting
class LogDetailsFormatButton extends StatelessWidget {
const LogDetailsFormatButton({super.key, required this.format});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class LogsTable extends StatelessWidget {
defaultSortDirection: SortDirection.ascending,
secondarySortColumn: messageColumn,
rowHeight: _logRowHeight,
tallHeaders: true,
);
}
}
Loading
Loading