blob: 5e784ea8f9c1da7ad4dcd60d1c384623770a9e0e [file] [log] [blame]
// Copyright 2019 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.
/// Inspector specific tree rendering support designed to be extendable to work
/// either directly with dart:html or with Hummingbird.
///
/// This library must not have direct dependencies on dart:html.
///
/// This allows tests of the complicated logic in this class to run on the VM
/// and will help simplify porting this code to work with Hummingbird.
library inspector_tree;
import 'dart:math' as math;
import 'package:meta/meta.dart';
import 'package:vm_service/vm_service.dart';
import '../ui/fake_flutter/fake_flutter.dart';
import '../ui/icons.dart';
import '../ui/material_icons.dart';
import '../ui/theme.dart';
import '../utils.dart';
import 'diagnostics_node.dart';
import 'inspector_controller.dart';
import 'inspector_service.dart';
import 'inspector_text_styles.dart' as inspector_text_styles;
/// Split text into two groups, word characters at the start of a string and all
/// other characters.
final RegExp _primaryDescriptionPattern = RegExp(r'^([\w ]+)(.*)$');
// TODO(jacobr): temporary workaround for missing structure from assertion thrown building
// widget errors.
final RegExp _assertionThrownBuildingError = RegExp(
r'^(The following assertion was thrown building [a-zA-Z]+)(\(.*\))(:)$');
final ColorIconMaker _colorIconMaker = ColorIconMaker();
final CustomIconMaker _customIconMaker = CustomIconMaker();
const bool _showRenderObjectPropertiesAsLinks = false;
typedef TreeEventCallback = void Function(InspectorTreeNode node);
typedef TreeHoverEventCallback = void Function(
InspectorTreeNode node,
Icon icon,
);
const Color selectedRowBackgroundColor = ThemedColor(
Color.fromARGB(255, 202, 191, 69),
Color.fromARGB(255, 99, 101, 103),
);
const Color hoverColor = ThemedColor(
Colors.yellowAccent,
Color.fromARGB(255, 70, 73, 76),
);
const Color highlightLineColor = ThemedColor(
Colors.black,
Color.fromARGB(255, 200, 200, 200),
);
const Color defaultTreeLineColor = ThemedColor(
Colors.grey,
Color.fromARGB(255, 150, 150, 150),
);
const double iconPadding = 5.0;
const double chartLineStrokeWidth = 1.0;
const double columnWidth = 16.0;
const double verticalPadding = 10.0;
const double rowHeight = 24.0;
const Color arrowColor = Colors.grey;
final Icon defaultIcon = _customIconMaker.fromInfo('Default');
// TODO(jacobr): these arrows are a bit ugly.
// We should create pngs instead of trying to stretch the material icons into
// being good expand collapse arrows.
final Icon collapseArrow = MaterialIcon(
'arrow_drop_down',
arrowColor,
fontSize: 32,
iconWidth: (columnWidth - iconPadding).toInt(),
);
final Icon expandArrow = MaterialIcon(
'arrow_drop_down',
arrowColor,
fontSize: 32,
angle: -math.pi / 2, // -90 degrees
iconWidth: (columnWidth - iconPadding).toInt(),
);
abstract class PaintEntry {
PaintEntry();
Icon get icon;
void attach(InspectorTree owner) {}
}
abstract class InspectorTreeNodeRenderBuilder<
R extends InspectorTreeNodeRender> {
InspectorTreeNodeRenderBuilder({
@required this.level,
@required this.treeStyle,
});
void appendText(String text, TextStyle textStyle);
void addIcon(Icon icon);
final DiagnosticLevel level;
final DiagnosticsTreeStyle treeStyle;
InspectorTreeNodeRender build();
}
abstract class InspectorTreeNodeRender<E extends PaintEntry> {
InspectorTreeNodeRender(this.entries, this.size);
final List<E> entries;
final Size size;
void attach(InspectorTree owner, Offset offset) {
if (_owner != owner) {
_owner = owner;
}
_offset = offset;
for (var entry in entries) {
entry.attach(owner);
}
}
/// Offset can be updated after the object is created by calling attach
/// with a new offset.
Offset get offset => _offset;
Offset _offset;
InspectorTree _owner;
Rect get paintBounds => _offset & size;
PaintEntry hitTest(Offset location);
}
/// This class could be refactored out to be a reasonable generic collapsible
/// tree ui node class but we choose to instead make it widget inspector
/// specific as that is the only case we care about.
// TODO(kenzie): extend TreeNode class to share tree logic.
abstract class InspectorTreeNode {
InspectorTreeNode({
InspectorTreeNode parent,
bool expandChildren = true,
}) : _children = <InspectorTreeNode>[],
_parent = parent,
_isExpanded = expandChildren;
bool get showLinesToChildren {
return _children.length > 1 && !_children.last.isProperty;
}
/// Override this method to define a tree node to build render objects
/// appropriate for a specific platform.
InspectorTreeNodeRenderBuilder createRenderBuilder();
/// This method defines the logic of how a RenderObject is converted to
/// a list of styled text and icons. If you want to change how tree content
/// is styled modify this message as it is the robust way for style changes
/// to apply to all ways inspector trees are rendered (html, canvas, Flutter
/// in the future).
/// If you change this rendering also change the matching logic in
/// src/io/flutter/view/DiagnosticsTreeCellRenderer.java
InspectorTreeNodeRender get renderObject {
if (_renderObject != null || diagnostic == null) {
return _renderObject;
}
final builder = createRenderBuilder();
final icon = diagnostic.icon;
if (showExpandCollapse) {
builder.addIcon(isExpanded ? collapseArrow : expandArrow);
}
if (icon != null) {
builder.addIcon(icon);
}
final String name = diagnostic.name;
TextStyle textStyle = textStyleForLevel(diagnostic.level);
if (diagnostic.isProperty) {
// Display of inline properties.
final String propertyType = diagnostic.propertyType;
final Map<String, Object> properties = diagnostic.valuePropertiesJson;
if (name?.isNotEmpty == true && diagnostic.showName) {
builder.appendText('$name${diagnostic.separator} ', textStyle);
}
if (isCreatedByLocalProject) {
textStyle = textStyle.merge(inspector_text_styles.regularBold);
}
String description = diagnostic.description;
if (propertyType != null && properties != null) {
switch (propertyType) {
case 'Color':
{
final int alpha = JsonUtils.getIntMember(properties, 'alpha');
final int red = JsonUtils.getIntMember(properties, 'red');
final int green = JsonUtils.getIntMember(properties, 'green');
final int blue = JsonUtils.getIntMember(properties, 'blue');
String radix(int chan) => chan.toRadixString(16).padLeft(2, '0');
if (alpha == 255) {
description = '#${radix(red)}${radix(green)}${radix(blue)}';
} else {
description =
'#${radix(alpha)}${radix(red)}${radix(green)}${radix(blue)}';
}
final Color color = Color.fromARGB(alpha, red, green, blue);
builder.addIcon(_colorIconMaker.getCustomIcon(color));
break;
}
case 'IconData':
{
final int codePoint =
JsonUtils.getIntMember(properties, 'codePoint');
if (codePoint > 0) {
final Icon icon =
FlutterMaterialIcons.getIconForCodePoint(codePoint);
if (icon != null) {
builder.addIcon(icon);
}
}
break;
}
}
}
if (_showRenderObjectPropertiesAsLinks &&
propertyType == 'RenderObject') {
textStyle = textStyle..merge(inspector_text_styles.link);
}
// TODO(jacobr): custom display for units, iterables, and padding.
_renderDescription(builder, description, textStyle, isProperty: true);
if (diagnostic.level == DiagnosticLevel.fine &&
diagnostic.hasDefaultValue) {
builder.appendText(' ', textStyle);
builder.addIcon(defaultIcon);
}
} else {
// Non property, regular node case.
if (name?.isNotEmpty == true && diagnostic.showName && name != 'child') {
if (name.startsWith('child ')) {
builder.appendText(name, inspector_text_styles.unimportant);
} else {
builder.appendText(name, textStyle);
}
if (diagnostic.showSeparator) {
builder.appendText(
diagnostic.separator, inspector_text_styles.unimportant);
if (diagnostic.separator != ' ' &&
diagnostic.description.isNotEmpty) {
builder.appendText(' ', inspector_text_styles.unimportant);
}
}
}
if (!diagnostic.isSummaryTree && diagnostic.isCreatedByLocalProject) {
textStyle = textStyle.merge(inspector_text_styles.regularBold);
}
_renderDescription(builder, diagnostic.description, textStyle,
isProperty: false);
}
_renderObject = builder.build();
return _renderObject;
}
InspectorTreeNodeRender _renderObject;
RemoteDiagnosticsNode _diagnostic;
final List<InspectorTreeNode> _children;
Iterable<InspectorTreeNode> get children => _children;
bool get isCreatedByLocalProject => _diagnostic.isCreatedByLocalProject;
bool get isProperty => diagnostic == null || diagnostic.isProperty;
bool get isExpanded => _isExpanded;
bool _isExpanded;
bool allowExpandCollapse = true;
bool get showExpandCollapse {
return (diagnostic?.hasChildren == true || children.isNotEmpty) &&
allowExpandCollapse;
}
set isExpanded(bool value) {
if (value != _isExpanded) {
_isExpanded = value;
dirty();
}
}
InspectorTreeNode get parent => _parent;
InspectorTreeNode _parent;
set parent(InspectorTreeNode value) {
_parent = value;
_parent?.dirty();
}
RemoteDiagnosticsNode get diagnostic => _diagnostic;
set diagnostic(RemoteDiagnosticsNode v) {
_diagnostic = v;
_isExpanded = v.childrenReady;
dirty();
}
void dirty() {
_renderObject = null;
if (_childrenCount == null) {
// Already dirty.
return;
}
_childrenCount = null;
if (parent != null) {
parent.dirty();
}
}
int get childrenCount {
if (!isExpanded) {
_childrenCount = 0;
}
if (_childrenCount != null) {
return _childrenCount;
}
int count = 0;
for (InspectorTreeNode child in _children) {
count += child.subtreeSize;
}
_childrenCount = count;
return _childrenCount;
}
bool get hasPlaceholderChildren {
return children.length == 1 && children.first.diagnostic == null;
}
int _childrenCount;
int get subtreeSize => childrenCount + 1;
bool get isLeaf => _children.isEmpty;
// TODO(jacobr): move getRowIndex to the InspectorTree class.
int getRowIndex(InspectorTreeNode node) {
int index = 0;
while (true) {
final InspectorTreeNode parent = node.parent;
if (parent == null) {
break;
}
for (InspectorTreeNode sibling in parent._children) {
if (sibling == node) {
break;
}
index += sibling.subtreeSize;
}
index += 1; // For parent itself.
node = parent;
}
return index;
}
/// TODO(jacobr): move this method to the InspectorTree class.
InspectorTreeRow getRow(int index, {InspectorTreeNode selection}) {
final List<int> ticks = <int>[];
int highlightDepth;
InspectorTreeNode node = this;
if (subtreeSize <= index) {
return null;
}
int current = 0;
int depth = 0;
while (node != null) {
final style = node.diagnostic?.style;
final bool indented = style != DiagnosticsTreeStyle.flat &&
style != DiagnosticsTreeStyle.error;
if (selection == node) {
highlightDepth = depth;
}
if (current == index) {
return InspectorTreeRow(
node: node,
index: index,
ticks: ticks,
depth: depth,
isSelected: selection == node,
highlightDepth: highlightDepth,
lineToParent:
!node.isProperty && index != 0 && node.parent.showLinesToChildren,
);
}
assert(index > current);
current++;
final List<InspectorTreeNode> children = node._children;
int i;
for (i = 0; i < children.length; ++i) {
final child = children[i];
final subtreeSize = child.subtreeSize;
if (current + subtreeSize > index) {
node = child;
if (children.length > 1 &&
i + 1 != children.length &&
!children.last.isProperty) {
if (indented) {
ticks.add(depth);
}
}
break;
}
current += subtreeSize;
}
assert(i < children.length);
if (indented) {
depth++;
}
}
assert(false); // internal error.
return null;
}
void removeChild(InspectorTreeNode child) {
child.parent = null;
final removed = _children.remove(child);
assert(removed != null);
dirty();
}
void appendChild(InspectorTreeNode child) {
_children.add(child);
child.parent = this;
dirty();
}
void clearChildren() {
_children.clear();
dirty();
}
void _renderDescription(
InspectorTreeNodeRenderBuilder<InspectorTreeNodeRender<PaintEntry>> builder,
String description,
TextStyle textStyle, {
bool isProperty,
}) {
if (diagnostic.isDiagnosticableValue) {
final match = _primaryDescriptionPattern.firstMatch(description);
if (match != null) {
builder.appendText(match.group(1), textStyle);
if (match.group(2).isNotEmpty) {
builder.appendText(match.group(2), inspector_text_styles.unimportant);
}
return;
}
} else if (diagnostic.type == 'ErrorDescription') {
final match = _assertionThrownBuildingError.firstMatch(description);
if (match != null) {
builder.appendText(match.group(1), textStyle);
builder.appendText(match.group(3), textStyle);
return;
}
}
if (description?.isNotEmpty == true) {
builder.appendText(description, textStyle);
}
}
}
/// A row in the tree with all information required to render it.
class InspectorTreeRow {
const InspectorTreeRow({
@required this.node,
@required this.index,
@required this.ticks,
@required this.depth,
@required this.isSelected,
@required this.highlightDepth,
@required this.lineToParent,
});
final InspectorTreeNode node;
/// Column indexes of ticks to draw lines from parents to children.
final List<int> ticks;
final int depth;
final int index;
final bool lineToParent;
final bool isSelected;
final int highlightDepth;
}
typedef InspectorTreeFactory = InspectorTree Function({
@required bool summaryTree,
@required FlutterTreeType treeType,
@required NodeAddedCallback onNodeAdded,
VoidCallback onSelectionChange,
TreeEventCallback onExpand,
TreeHoverEventCallback onHover,
});
/// Callback issued every time a node is added to the tree.
typedef NodeAddedCallback = void Function(
InspectorTreeNode node, RemoteDiagnosticsNode diagnosticsNode);
abstract class InspectorTree {
InspectorTree({
@required this.summaryTree,
@required this.treeType,
@required NodeAddedCallback onNodeAdded,
VoidCallback onSelectionChange,
this.onExpand,
TreeHoverEventCallback onHover,
}) : _onHoverCallback = onHover,
_onSelectionChange = onSelectionChange,
_onNodeAdded = onNodeAdded;
final TreeHoverEventCallback _onHoverCallback;
final TreeEventCallback onExpand;
final VoidCallback _onSelectionChange;
final NodeAddedCallback _onNodeAdded;
InspectorTreeNode get root => _root;
InspectorTreeNode _root;
set root(InspectorTreeNode node) {
setState(() {
_root = node;
});
}
RemoteDiagnosticsNode subtreeRoot; // Optional.
InspectorTreeNode get selection => _selection;
InspectorTreeNode _selection;
set selection(InspectorTreeNode node) {
setState(() {
_selection = node;
expandPath(node);
if (_onSelectionChange != null) {
_onSelectionChange();
}
});
}
InspectorTreeNode get hover => _hover;
InspectorTreeNode _hover;
final bool summaryTree;
final FlutterTreeType treeType;
void setState(VoidCallback modifyState);
InspectorTreeNode createNode();
double getRowOffset(int index) {
return (root.getRow(index)?.depth ?? 0) * columnWidth;
}
set hover(InspectorTreeNode node) {
if (node == _hover) {
return;
}
setState(() {
_hover = node;
// TODO(jacobr): we could choose to repaint only a portion of the UI
});
}
String get tooltip;
set tooltip(String value);
RemoteDiagnosticsNode _currentHoverDiagnostic;
bool _computingHover = false;
Future<void> onHover(InspectorTreeNode node, PaintEntry entry) async {
if (_onHoverCallback != null) {
_onHoverCallback(node, entry?.icon);
}
final diagnostic = node?.diagnostic;
final lastHover = _currentHoverDiagnostic;
_currentHoverDiagnostic = diagnostic;
// Only show tooltips when we are hovering over specific content in a row
// rather than over the entire row.
// TODO(jacobr): consider showing the tooltip any time we are on a row with
// a diagnostics node to make tooltips more discoverable.
// To make this work well we would need to add custom tooltip rendering that
// more clearly links tooltips to the exact content in a row they apply to.
if (diagnostic == null || entry == null) {
tooltip = '';
_computingHover = false;
return;
}
if (entry.icon == defaultIcon) {
tooltip = 'Default value';
_computingHover = false;
return;
}
if (diagnostic.isEnumProperty()) {
// We can display a better tooltip than the one provied with the
// RemoteDiagnosticsNode as we have access to introspection
// via the vm service.
if (lastHover == diagnostic && _computingHover) {
// No need to spam the VMService. We are already computing the hover
// for this node.
return;
}
_computingHover = true;
Map<String, InstanceRef> properties;
try {
properties = await diagnostic.valueProperties;
} finally {
_computingHover = false;
}
if (lastHover != diagnostic) {
// Skipping as the tooltip is no longer relevant for the currently
// hovered over node.
return;
}
if (properties == null) {
// Something went wrong getting the enum value.
// Fall back to the regular tooltip;
tooltip = diagnostic.tooltip;
return;
}
tooltip = 'Allowed values:\n${properties.keys.join('\n')}';
return;
}
tooltip = diagnostic.tooltip;
_computingHover = false;
}
double get horizontalPadding => 10.0;
double getDepthIndent(int depth) {
return (depth + 1) * columnWidth + horizontalPadding;
}
double getRowY(int index) {
return rowHeight * index + verticalPadding;
}
void nodeChanged(InspectorTreeNode node) {
if (node == null) return;
setState(() {
node.dirty();
});
}
void removeNodeFromParent(InspectorTreeNode node) {
setState(() {
node.parent?.removeChild(node);
});
}
void appendChild(InspectorTreeNode node, InspectorTreeNode child) {
setState(() {
node.appendChild(child);
});
}
void expandPath(InspectorTreeNode node) {
setState(() {
while (node != null) {
if (!node.isExpanded) {
node.isExpanded = true;
}
node = node.parent;
}
});
}
int get numRows => root != null ? root.subtreeSize : 0;
int getRowIndex(double y) => (y - verticalPadding) ~/ rowHeight;
InspectorTreeRow getRowForNode(InspectorTreeNode node) {
return root.getRow(root.getRowIndex(node));
}
InspectorTreeRow getRow(Offset offset) {
if (root == null) return null;
final int row = getRowIndex(offset.dy);
return row < root.subtreeSize ? root.getRow(row) : null;
}
void animateToTargets(List<InspectorTreeNode> targets);
void onTap(Offset offset) {
final row = getRow(offset);
if (row == null) {
return;
}
onTapIcon(row, row.node.renderObject?.hitTest(offset)?.icon);
}
void onTapIcon(InspectorTreeRow row, Icon icon) {
if (icon == expandArrow) {
setState(() {
row.node.isExpanded = true;
if (onExpand != null) {
onExpand(row.node);
}
});
return;
}
if (icon == collapseArrow) {
setState(() {
row.node.isExpanded = false;
});
return;
}
// TODO(jacobr): add other interactive elements here.
selection = row.node;
}
bool expandPropertiesByDefault(DiagnosticsTreeStyle style) {
// This code matches the text style defaults for which styles are
// by default and which aren't.
switch (style) {
case DiagnosticsTreeStyle.none:
case DiagnosticsTreeStyle.singleLine:
case DiagnosticsTreeStyle.errorProperty:
return false;
case DiagnosticsTreeStyle.sparse:
case DiagnosticsTreeStyle.offstage:
case DiagnosticsTreeStyle.dense:
case DiagnosticsTreeStyle.transition:
case DiagnosticsTreeStyle.error:
case DiagnosticsTreeStyle.whitespace:
case DiagnosticsTreeStyle.flat:
case DiagnosticsTreeStyle.shallow:
case DiagnosticsTreeStyle.truncateChildren:
return true;
}
return true;
}
InspectorTreeNode setupInspectorTreeNode(
InspectorTreeNode node,
RemoteDiagnosticsNode diagnosticsNode, {
@required bool expandChildren,
@required bool expandProperties,
}) {
assert(expandChildren != null);
assert(expandProperties != null);
node.diagnostic = diagnosticsNode;
if (_onNodeAdded != null) {
_onNodeAdded(node, diagnosticsNode);
}
if (diagnosticsNode.hasChildren ||
diagnosticsNode.inlineProperties.isNotEmpty) {
if (diagnosticsNode.childrenReady || !diagnosticsNode.hasChildren) {
final bool styleIsMultiline =
expandPropertiesByDefault(diagnosticsNode.style);
setupChildren(
diagnosticsNode,
node,
node.diagnostic.childrenNow,
expandChildren: expandChildren && styleIsMultiline,
expandProperties: expandProperties && styleIsMultiline,
);
} else {
node.clearChildren();
node.appendChild(createNode());
}
}
return node;
}
void setupChildren(
RemoteDiagnosticsNode parent,
InspectorTreeNode treeNode,
List<RemoteDiagnosticsNode> children, {
@required bool expandChildren,
@required bool expandProperties,
}) {
assert(expandChildren != null);
assert(expandProperties != null);
treeNode.isExpanded = expandChildren;
if (treeNode.children.isNotEmpty) {
// Only case supported is this is the loading node.
assert(treeNode.children.length == 1);
removeNodeFromParent(treeNode.children.first);
}
final inlineProperties = parent.inlineProperties;
if (inlineProperties != null) {
for (RemoteDiagnosticsNode property in inlineProperties) {
appendChild(
treeNode,
setupInspectorTreeNode(
createNode(),
property,
// We are inside a property so only expand children if
// expandProperties is true.
expandChildren: expandProperties,
expandProperties: expandProperties,
),
);
}
}
if (children != null) {
for (RemoteDiagnosticsNode child in children) {
appendChild(
treeNode,
setupInspectorTreeNode(
createNode(),
child,
expandChildren: expandChildren,
expandProperties: expandProperties,
),
);
}
}
}
Future<void> maybePopulateChildren(InspectorTreeNode treeNode) async {
final RemoteDiagnosticsNode diagnostic = treeNode.diagnostic;
if (diagnostic != null &&
diagnostic.hasChildren &&
(treeNode.hasPlaceholderChildren || treeNode.children.isEmpty)) {
try {
final children = await diagnostic.children;
if (treeNode.hasPlaceholderChildren || treeNode.children.isEmpty) {
setupChildren(
diagnostic,
treeNode,
children,
expandChildren: true,
expandProperties: false,
);
nodeChanged(treeNode);
if (treeNode == selection) {
expandPath(treeNode);
}
}
} catch (e) {
print(e);
}
}
}
}
abstract class InspectorTreeFixedRowHeight extends InspectorTree {
InspectorTreeFixedRowHeight({
@required bool summaryTree,
@required FlutterTreeType treeType,
@required NodeAddedCallback onNodeAdded,
VoidCallback onSelectionChange,
TreeEventCallback onExpand,
TreeHoverEventCallback onHover,
}) : super(
summaryTree: summaryTree,
treeType: treeType,
onNodeAdded: onNodeAdded,
onSelectionChange: onSelectionChange,
onExpand: onExpand,
onHover: onHover,
);
Rect getBoundingBox(InspectorTreeRow row);
void scrollToRect(Rect targetRect);
/// The future completes when the possible tooltip on hover is available.
///
/// Generally only await this future for tests that check for the value shown
/// on hover matches the expected value.
Future<void> onMouseMove(Offset offset) async {
final row = getRow(offset);
if (row != null) {
final node = row.node;
await onHover(node, node?.renderObject?.hitTest(offset));
} else {
await onHover(null, null);
}
}
@override
void animateToTargets(List<InspectorTreeNode> targets) {
Rect targetRect;
for (InspectorTreeNode target in targets) {
final row = getRowForNode(target);
if (row != null) {
final rowRect = getBoundingBox(row);
targetRect =
targetRect == null ? rowRect : targetRect.expandToInclude(rowRect);
}
}
if (targetRect == null || targetRect.isEmpty) return;
targetRect = targetRect.inflate(20.0);
scrollToRect(targetRect);
}
}