Implement new selection UI for Timeline page (#2067)
* Paint brackets around selected timeline frame events.
diff --git a/packages/devtools_app/lib/src/charts/flame_chart.dart b/packages/devtools_app/lib/src/charts/flame_chart.dart
index 96bc62e..2c2c3f5 100644
--- a/packages/devtools_app/lib/src/charts/flame_chart.dart
+++ b/packages/devtools_app/lib/src/charts/flame_chart.dart
@@ -769,7 +769,6 @@
onSelected = ((_) {}),
selectable = false;
- static const _selectedNodeColor = lightSelection;
static const _selectedTextColor = Colors.black;
// We would like this value to be smaller, but zoom performance does not allow
// for that. We should decrease this value if we can improve flame chart zoom
@@ -840,7 +839,7 @@
}
Color _backgroundColor(bool selected) {
- if (selected) return _selectedNodeColor;
+ if (selected) return timelineSelectionColor;
return backgroundColor;
}
@@ -965,3 +964,31 @@
return index;
}
}
+
+abstract class FlameChartPainter extends CustomPainter {
+ FlameChartPainter({
+ @required this.zoom,
+ @required this.constraints,
+ @required this.verticalScrollOffset,
+ @required this.horizontalScrollOffset,
+ @required this.chartStartInset,
+ }) : visible = Rect.fromLTWH(
+ horizontalScrollOffset,
+ verticalScrollOffset,
+ constraints.maxWidth,
+ constraints.maxHeight,
+ );
+
+ final double zoom;
+
+ final BoxConstraints constraints;
+
+ final double verticalScrollOffset;
+
+ final double horizontalScrollOffset;
+
+ final double chartStartInset;
+
+ /// The absolute coordinates of the flame chart's visible section.
+ final Rect visible;
+}
diff --git a/packages/devtools_app/lib/src/timeline/flutter_frames_chart.dart b/packages/devtools_app/lib/src/timeline/flutter_frames_chart.dart
index d8b6912..c2329e9 100644
--- a/packages/devtools_app/lib/src/timeline/flutter_frames_chart.dart
+++ b/packages/devtools_app/lib/src/timeline/flutter_frames_chart.dart
@@ -213,6 +213,11 @@
static const defaultFrameWidth = 32.0;
+ static const selectedIndicatorHeight = 8.0;
+
+ static const selectedFrameIndicatorKey =
+ Key('flutter frames chart - selected frame indicator');
+
final TimelineFrame frame;
final bool selected;
@@ -243,32 +248,42 @@
(frame.rasterDurationMs / msPerPx).clamp(0.0, availableChartHeight),
color: janky ? rasterJankColor : mainRasterColor,
);
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: densePadding),
- color: selected ? selectedFrameBackgroundColor : null,
- child: Column(
- children: [
- // Dummy child so that the InkWell does not take up the entire column.
- const Expanded(child: SizedBox()),
- InkWell(
- // TODO(kenz): make tooltip to persist if the frame is selected.
- // TODO(kenz): change color on hover.
- onTap: onSelected,
- child: Tooltip(
- message: _tooltipText(frame),
- padding: const EdgeInsets.all(denseSpacing),
- preferBelow: false,
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.end,
- children: [
- ui,
- raster,
- ],
+ return Stack(
+ children: [
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: densePadding),
+ color: selected ? selectedFrameBackgroundColor : null,
+ child: Column(
+ children: [
+ // Dummy child so that the InkWell does not take up the entire column.
+ const Expanded(child: SizedBox()),
+ InkWell(
+ // TODO(kenz): make tooltip to persist if the frame is selected.
+ // TODO(kenz): change color on hover.
+ onTap: onSelected,
+ child: Tooltip(
+ message: _tooltipText(frame),
+ padding: const EdgeInsets.all(denseSpacing),
+ preferBelow: false,
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.end,
+ children: [
+ ui,
+ raster,
+ ],
+ ),
+ ),
),
- ),
+ ],
),
- ],
- ),
+ ),
+ if (selected)
+ Container(
+ key: selectedFrameIndicatorKey,
+ color: timelineSelectionColor,
+ height: selectedIndicatorHeight,
+ ),
+ ],
);
}
diff --git a/packages/devtools_app/lib/src/timeline/timeline_flame_chart.dart b/packages/devtools_app/lib/src/timeline/timeline_flame_chart.dart
index c550816..fe3ea22 100644
--- a/packages/devtools_app/lib/src/timeline/timeline_flame_chart.dart
+++ b/packages/devtools_app/lib/src/timeline/timeline_flame_chart.dart
@@ -103,7 +103,9 @@
final verticalScrollOffset =
eventGroupStartXValues[widget.data.eventGroups[TimelineData.uiKey]];
await verticalScrollController.animateTo(
- verticalScrollOffset,
+ // Subtract [rowHeightWithPadding] to ensure the selection brackets fit
+ // in view.
+ verticalScrollOffset - rowHeightWithPadding,
duration: shortDuration,
curve: defaultCurve,
);
@@ -272,10 +274,11 @@
@override
List<CustomPaint> buildCustomPaints(BoxConstraints constraints) {
+ final zoom = zoomController.value;
return [
CustomPaint(
painter: AsyncGuidelinePainter(
- zoom: zoomController.value,
+ zoom: zoom,
constraints: constraints,
verticalScrollOffset: verticalScrollOffset,
horizontalScrollOffset: horizontalScrollOffset,
@@ -286,7 +289,7 @@
),
CustomPaint(
painter: TimelineGridPainter(
- zoom: zoomController.value,
+ zoom: zoom,
constraints: constraints,
verticalScrollOffset: verticalScrollOffset,
horizontalScrollOffset: horizontalScrollOffset,
@@ -296,6 +299,23 @@
duration: widget.time.duration,
),
),
+ CustomPaint(
+ painter: SelectedFrameBracketPainter(
+ _selectedFrame,
+ zoom: zoom,
+ constraints: constraints,
+ verticalScrollOffset: verticalScrollOffset,
+ horizontalScrollOffset: horizontalScrollOffset,
+ chartStartInset: widget.startInset,
+ startTimeOffsetMicros: startTimeOffset,
+ startingPxPerMicro: startingPxPerMicro,
+ // Subtract [rowHeight] because [_calculateVerticalGuidelineStartY]
+ // returns the Y value at the bottom of the flame chart node, and we
+ // want the Y value at the top of the node.
+ yForEvent: (event) =>
+ _calculateVerticalGuidelineStartY(event) - rowHeight,
+ ),
+ ),
];
}
@@ -432,51 +452,40 @@
}
}
-class AsyncGuidelinePainter extends CustomPainter {
+class AsyncGuidelinePainter extends FlameChartPainter {
AsyncGuidelinePainter({
- @required this.zoom,
- @required this.constraints,
- @required this.verticalScrollOffset,
- @required this.horizontalScrollOffset,
+ @required double zoom,
+ @required BoxConstraints constraints,
+ @required double verticalScrollOffset,
+ @required double horizontalScrollOffset,
+ @required double chartStartInset,
@required this.verticalGuidelines,
@required this.horizontalGuidelines,
- @required this.chartStartInset,
- });
-
- final double zoom;
-
- final BoxConstraints constraints;
-
- final double verticalScrollOffset;
-
- final double horizontalScrollOffset;
+ }) : super(
+ zoom: zoom,
+ constraints: constraints,
+ verticalScrollOffset: verticalScrollOffset,
+ horizontalScrollOffset: horizontalScrollOffset,
+ chartStartInset: chartStartInset,
+ );
final List<VerticalLineSegment> verticalGuidelines;
final List<HorizontalLineSegment> horizontalGuidelines;
- final double chartStartInset;
-
@override
void paint(Canvas canvas, Size size) {
- final visible = Rect.fromLTWH(
- horizontalScrollOffset,
- verticalScrollOffset,
- constraints.maxWidth,
- constraints.maxHeight,
- );
-
// The guideline objects are calculated with a base zoom level of 1.0. We
// need to convert the zoomed left offset into the unzoomed left offset for
// proper calculation of the first vertical guideline index.
final unzoomedOffset = math.max(0.0, visible.left - chartStartInset) / zoom;
- final leftBoundWithZoom = chartStartInset + unzoomedOffset;
+ final leftBoundWithoutZoom = chartStartInset + unzoomedOffset;
final firstVerticalGuidelineIndex = lowerBound(
verticalGuidelines,
VerticalLineSegment(
- Offset(leftBoundWithZoom, visible.top),
- Offset(leftBoundWithZoom, visible.bottom),
+ Offset(leftBoundWithoutZoom, visible.top),
+ Offset(leftBoundWithoutZoom, visible.bottom),
),
);
final firstHorizontalGuidelineIndex = lowerBound(
@@ -567,17 +576,23 @@
bool shouldRepaint(AsyncGuidelinePainter oldDelegate) => true;
}
-class TimelineGridPainter extends CustomPainter {
+class TimelineGridPainter extends FlameChartPainter {
TimelineGridPainter({
- @required this.zoom,
- @required this.constraints,
- @required this.verticalScrollOffset,
- @required this.horizontalScrollOffset,
- @required this.chartStartInset,
+ @required double zoom,
+ @required BoxConstraints constraints,
+ @required double verticalScrollOffset,
+ @required double horizontalScrollOffset,
+ @required double chartStartInset,
@required this.chartEndInset,
@required this.flameChartWidth,
@required this.duration,
- });
+ }) : super(
+ zoom: zoom,
+ constraints: constraints,
+ verticalScrollOffset: verticalScrollOffset,
+ horizontalScrollOffset: horizontalScrollOffset,
+ chartStartInset: chartStartInset,
+ );
static const baseGridIntervalPx = 150.0;
static const timestampOffset = 6.0;
@@ -586,18 +601,6 @@
Color(0xFFFAFBFC),
);
- static const origin = 0.0;
-
- final double zoom;
-
- final BoxConstraints constraints;
-
- final double verticalScrollOffset;
-
- final double horizontalScrollOffset;
-
- final double chartStartInset;
-
final double chartEndInset;
final double flameChartWidth;
@@ -606,20 +609,12 @@
@override
void paint(Canvas canvas, Size size) {
- // The absolute coordinates of the flame chart's visible section.
- final visible = Rect.fromLTWH(
- horizontalScrollOffset,
- verticalScrollOffset,
- constraints.maxWidth,
- constraints.maxHeight,
- );
-
// Paint background for the section that will contain the timestamps. This
// section will appear sticky to the top of the viewport.
canvas.drawRect(
Rect.fromLTWH(
- origin,
- origin,
+ 0.0,
+ 0.0,
constraints.maxWidth,
math.min(constraints.maxHeight, rowHeight),
),
@@ -671,13 +666,13 @@
// of text widgets for the timestamps instead of painting them.
final xOffset = lineX - textPainter.width - timestampOffset;
if (xOffset > 0) {
- textPainter.paint(canvas, Offset(xOffset, origin + 5.0));
+ textPainter.paint(canvas, Offset(xOffset, 5.0));
}
}
void _paintGridLine(Canvas canvas, double lineX) {
canvas.drawLine(
- Offset(lineX, origin),
+ Offset(lineX, 0.0),
Offset(lineX, constraints.maxHeight),
Paint()..color = chartAccentColor,
);
@@ -734,6 +729,174 @@
);
}
+class SelectedFrameBracketPainter extends FlameChartPainter {
+ SelectedFrameBracketPainter(
+ this.selectedFrame, {
+ @required double zoom,
+ @required BoxConstraints constraints,
+ @required double verticalScrollOffset,
+ @required double horizontalScrollOffset,
+ @required double chartStartInset,
+ @required this.startTimeOffsetMicros,
+ @required this.startingPxPerMicro,
+ @required this.yForEvent,
+ }) : super(
+ zoom: zoom,
+ constraints: constraints,
+ verticalScrollOffset: verticalScrollOffset,
+ horizontalScrollOffset: horizontalScrollOffset,
+ chartStartInset: chartStartInset,
+ );
+
+ static const strokeWidth = 4.0;
+ static const bracketWidth = 24.0;
+ static const bracketCurveWidth = 8.0;
+ static const bracketVerticalPadding = 8.0;
+
+ final TimelineFrame selectedFrame;
+
+ final int startTimeOffsetMicros;
+
+ final double startingPxPerMicro;
+
+ final double Function(TimelineEvent) yForEvent;
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ if (selectedFrame == null) return;
+
+ canvas.clipRect(Rect.fromLTWH(
+ 0.0,
+ rowHeight, // We do not want to paint inside the timestamp section.
+ constraints.maxWidth,
+ constraints.maxHeight - rowHeight,
+ ));
+
+ final paint = Paint()
+ ..color = timelineSelectionColor
+ ..style = PaintingStyle.stroke
+ ..strokeWidth = strokeWidth;
+
+ _paintBrackets(canvas, paint, event: selectedFrame.uiEventFlow);
+ _paintBrackets(canvas, paint, event: selectedFrame.rasterEventFlow);
+ }
+
+ void _paintBrackets(
+ Canvas canvas,
+ Paint paint, {
+ @required TimelineEvent event,
+ }) {
+ final startMicros = event.time.start.inMicroseconds - startTimeOffsetMicros;
+ final endMicros = event.time.end.inMicroseconds - startTimeOffsetMicros;
+ final startPx = startMicros * startingPxPerMicro * zoom + chartStartInset;
+ final endPx = endMicros * startingPxPerMicro * zoom + chartStartInset;
+
+ final startBracketX =
+ startPx - visible.left + (bracketWidth - bracketCurveWidth);
+ final endBracketX =
+ endPx - visible.left - (bracketWidth - bracketCurveWidth);
+ final bracketTopY = yForEvent(event) - visible.top - bracketVerticalPadding;
+ final bracketBottomY = bracketTopY +
+ event.depth * rowHeightWithPadding -
+ rowPadding +
+ bracketVerticalPadding * 2;
+
+ // Draw the start bracket.
+ canvas.drawPath(
+ Path()
+ ..moveTo(startBracketX, bracketTopY)
+ ..lineTo(startBracketX - bracketWidth + bracketCurveWidth, bracketTopY)
+ ..arcTo(
+ Rect.fromLTWH(
+ startBracketX - bracketWidth,
+ bracketTopY,
+ bracketCurveWidth * 2,
+ bracketCurveWidth * 2,
+ ),
+ degToRad(270),
+ degToRad(-90),
+ false,
+ )
+ ..lineTo(
+ startBracketX - bracketWidth,
+ bracketBottomY - bracketWidth,
+ )
+ ..arcTo(
+ Rect.fromLTWH(
+ startBracketX - bracketWidth,
+ bracketBottomY - bracketVerticalPadding * 2,
+ bracketCurveWidth * 2,
+ bracketCurveWidth * 2,
+ ),
+ degToRad(180),
+ degToRad(-90),
+ false,
+ )
+ ..lineTo(startBracketX, bracketBottomY),
+ paint,
+ );
+
+ // Draw the end bracket.
+ // TODO(kenz): reuse the path of the start bracket and transform it to draw
+ // the end bracket.
+ canvas.drawPath(
+ Path()
+ ..moveTo(endBracketX, bracketTopY)
+ ..lineTo(endBracketX + bracketWidth - bracketCurveWidth, bracketTopY)
+ ..arcTo(
+ Rect.fromLTWH(
+ endBracketX + bracketWidth - bracketCurveWidth * 2,
+ bracketTopY,
+ bracketCurveWidth * 2,
+ bracketCurveWidth * 2,
+ ),
+ degToRad(270),
+ degToRad(90),
+ false,
+ )
+ ..lineTo(
+ endBracketX + bracketWidth,
+ bracketBottomY - bracketWidth,
+ )
+ ..arcTo(
+ Rect.fromLTWH(
+ endBracketX + bracketWidth - bracketCurveWidth * 2,
+ bracketBottomY - bracketVerticalPadding * 2,
+ bracketCurveWidth * 2,
+ bracketCurveWidth * 2,
+ ),
+ degToRad(0),
+ degToRad(90),
+ false,
+ )
+ ..lineTo(endBracketX, bracketBottomY),
+ paint,
+ );
+ }
+
+ @override
+ bool shouldRepaint(SelectedFrameBracketPainter oldDelegate) =>
+ this != oldDelegate;
+
+ @override
+ bool operator ==(other) {
+ return selectedFrame == other.selectedFrame &&
+ zoom == other.zoom &&
+ constraints == other.constraints &&
+ verticalScrollOffset == other.verticalScrollOffset &&
+ horizontalScrollOffset == other.horizontalScrollOffset;
+ }
+
+ @override
+ int get hashCode => hashValues(
+ selectedFrame,
+ zoom,
+ constraints,
+ verticalScrollOffset,
+ horizontalScrollOffset,
+ );
+}
+
extension TimelineEventGroupDisplayExtension on TimelineEventGroup {
int get displaySize => rows.length + FlameChart.rowOffsetForSectionSpacer;
diff --git a/packages/devtools_app/lib/src/ui/colors.dart b/packages/devtools_app/lib/src/ui/colors.dart
index 889df0d..3096989 100644
--- a/packages/devtools_app/lib/src/ui/colors.dart
+++ b/packages/devtools_app/lib/src/ui/colors.dart
@@ -30,7 +30,7 @@
const selectedFrameBackgroundColor =
ThemedColor(Color(0xFFDBDDDD), Color(0xFF4E4F4F));
-const selectedFrameAccentColor = Color(0xFF36C6F4);
+const timelineSelectionColor = Color(0xFF36C6F4);
// Teal 200, 400 - see https://material.io/design/color/#tools-for-picking-colors.
const asyncColorPalette = [
diff --git a/packages/devtools_app/lib/src/utils.dart b/packages/devtools_app/lib/src/utils.dart
index 5b268bf..51d86e8 100644
--- a/packages/devtools_app/lib/src/utils.dart
+++ b/packages/devtools_app/lib/src/utils.dart
@@ -792,3 +792,6 @@
}).join();
}
}
+
+// Method to convert degrees to radians
+num degToRad(num deg) => deg * (pi / 180.0);
diff --git a/packages/devtools_app/test/flame_chart_test.dart b/packages/devtools_app/test/flame_chart_test.dart
index c92c203..81c1ff3 100644
--- a/packages/devtools_app/test/flame_chart_test.dart
+++ b/packages/devtools_app/test/flame_chart_test.dart
@@ -6,8 +6,8 @@
import 'package:devtools_app/src/flutter_widgets/linked_scroll_controller.dart';
import 'package:devtools_app/src/profiler/cpu_profile_model.dart';
import 'package:devtools_app/src/profiler/cpu_profile_flame_chart.dart';
-import 'package:devtools_app/src/theme.dart';
import 'package:devtools_app/src/timeline/timeline_model.dart';
+import 'package:devtools_app/src/ui/colors.dart';
import 'package:devtools_app/src/utils.dart';
import 'package:devtools_testing/support/cpu_profile_test_data.dart';
import 'package:devtools_testing/support/timeline_test_data.dart';
@@ -315,7 +315,7 @@
await pumpFlameChartNode(tester, selected: true, hovered: false);
expect(nodeFinder, findsOneWidget);
final Container nodeWidget = tester.widget(nodeFinder);
- expect(nodeWidget.color, equals(lightSelection));
+ expect(nodeWidget.color, equals(timelineSelectionColor));
expect(textFinder, findsOneWidget);
final Text textWidget = tester.widget(textFinder);
diff --git a/packages/devtools_app/test/flutter_frames_chart_test.dart b/packages/devtools_app/test/flutter_frames_chart_test.dart
index d62c078..e602bb3 100644
--- a/packages/devtools_app/test/flutter_frames_chart_test.dart
+++ b/packages/devtools_app/test/flutter_frames_chart_test.dart
@@ -29,7 +29,7 @@
expect(find.byType(FlutterFramesChart), findsOneWidget);
}
- group('TimelineScreen', () {
+ group('FlutterFramesChart', () {
setUp(() async {
setGlobal(
ServiceConnectionManager, FakeServiceManager(useFakeService: true));
@@ -60,4 +60,36 @@
expect(raster.color, equals(rasterJankColor));
});
});
+
+ group('FlutterFramesChartItem', () {
+ testWidgets('builds for selected frame', (WidgetTester tester) async {
+ await tester.pumpWidget(
+ // FlutterFramesChartItem needs to be wrapped in Material,
+ // Directionality, and Overlay in order to pump the widget and test.
+ Material(
+ child: Directionality(
+ textDirection: TextDirection.ltr,
+ child: Overlay(
+ initialEntries: [
+ OverlayEntry(
+ builder: (context) {
+ return FlutterFramesChartItem(
+ frame: testFrame0,
+ selected: true,
+ onSelected: () {},
+ msPerPx: 1,
+ availableChartHeight: 100.0,
+ displayRefreshRate: defaultRefreshRate,
+ );
+ },
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ expect(find.byKey(FlutterFramesChartItem.selectedFrameIndicatorKey),
+ findsOneWidget);
+ });
+ });
}
diff --git a/packages/devtools_app/test/goldens/timeline_flame_chart_with_selected_frame.png b/packages/devtools_app/test/goldens/timeline_flame_chart_with_selected_frame.png
new file mode 100644
index 0000000..06022e1
--- /dev/null
+++ b/packages/devtools_app/test/goldens/timeline_flame_chart_with_selected_frame.png
Binary files differ
diff --git a/packages/devtools_app/test/timeline_flame_chart_test.dart b/packages/devtools_app/test/timeline_flame_chart_test.dart
index bf5fe32..c8bb185 100644
--- a/packages/devtools_app/test/timeline_flame_chart_test.dart
+++ b/packages/devtools_app/test/timeline_flame_chart_test.dart
@@ -71,5 +71,21 @@
findsOneWidget,
);
});
+
+ testWidgetsWithWindowSize(
+ 'builds flame chart with selected frame', windowSize,
+ (WidgetTester tester) async {
+ final controller = TimelineController();
+ await pumpTimelineBody(tester, controller);
+ expect(controller.data.frames.length, equals(1));
+ controller.selectFrame(controller.data.frames.first);
+ await tester.pumpAndSettle();
+
+ expect(find.byType(TimelineFlameChart), findsOneWidget);
+ expect(
+ find.byType(TimelineFlameChart),
+ matchesGoldenFile(
+ 'goldens/timeline_flame_chart_with_selected_frame.png'));
+ });
});
}
diff --git a/packages/devtools_testing/lib/support/timeline_test_data.dart b/packages/devtools_testing/lib/support/timeline_test_data.dart
index d4a96c9..a870baa 100644
--- a/packages/devtools_testing/lib/support/timeline_test_data.dart
+++ b/packages/devtools_testing/lib/support/timeline_test_data.dart
@@ -1028,7 +1028,9 @@
'tid': testRasterThreadId,
'args': {'name': '1.raster'},
},
+ frameStartEvent.json,
...goldenTraceEventsJson,
+ frameEndEvent.json,
],
'timeOriginMicros': 118039650802,
'timeExtentMicros': 118039679873 - 118039650802,