<commit_before>// 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.
import 'dart:collection';
import 'dart:math' as math;
import 'dart:ui';

import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

import '../auto_dispose_mixin.dart';
import '../charts/flame_chart.dart';
import '../common_widgets.dart';
import '../geometry.dart';
import '../theme.dart';
import '../ui/colors.dart';
import '../ui/search.dart';
import '../ui/theme.dart';
import '../utils.dart';
import 'timeline_controller.dart';
import 'timeline_model.dart';
import 'timeline_utils.dart';

final timelineSearchFieldKey = GlobalKey(debugLabel: 'TimelineSearchFieldKey');

class TimelineFlameChartContainer extends StatefulWidget {
  const TimelineFlameChartContainer({
    @required this.processing,
    @required this.processingProgress,
  });

  @visibleForTesting
  static const emptyTimelineKey = Key('Empty Timeline');

  final bool processing;

  final double processingProgress;

  @override
  _TimelineFlameChartContainerState createState() =>
      _TimelineFlameChartContainerState();
}

class _TimelineFlameChartContainerState
    extends State<TimelineFlameChartContainer>
    with AutoDisposeMixin, SearchFieldMixin {
  TimelineController controller;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();

    final newController = Provider.of<TimelineController>(context);
    if (newController == controller) return;
    controller = newController;
  }

  @override
  Widget build(BuildContext context) {
    Widget content;
    final timelineEmpty = (controller.data?.isEmpty ?? true) ||
        controller.data.eventGroups.isEmpty;
    if (widget.processing || timelineEmpty) {
      content = ValueListenableBuilder<bool>(
        valueListenable: controller.emptyTimeline,
        builder: (context, emptyRecording, _) {
          return emptyRecording
              ? const Center(
                  key: TimelineFlameChartContainer.emptyTimelineKey,
                  child: Text('No timeline events'),
                )
              : _buildProcessingInfo();
        },
      );
    } else {
      content = LayoutBuilder(
        builder: (context, constraints) {
          return TimelineFlameChart(
            controller.data,
            width: constraints.maxWidth,
            height: constraints.maxHeight,
            selectionNotifier: controller.selectedTimelineEvent,
            searchMatchesNotifier: controller.searchMatches,
            activeSearchMatchNotifier: controller.activeSearchMatch,
            onSelection: (e) => controller.selectTimelineEvent(e),
          );
        },
      );
    }

    final searchFieldEnabled =
        !(controller.data?.isEmpty ?? true) && !widget.processing;
    return OutlineDecoration(
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.end,
        children: [
          areaPaneHeader(
            context,
            title: 'Timeline Events',
            tall: true,
            needsTopBorder: false,
            actions: [
              Container(
                width: wideSearchTextWidth,
                height: defaultSearchTextHeight,
                child: buildSearchField(
                  controller: controller,
                  searchFieldKey: timelineSearchFieldKey,
                  searchFieldEnabled: searchFieldEnabled,
                  shouldRequestFocus: searchFieldEnabled,
                  supportsNavigation: true,
                ),
              ),
            ],
          ),
          Expanded(
            child: Padding(
              padding: const EdgeInsets.only(bottom: 8.0),
              child: content,
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildProcessingInfo() {
    return processingInfo(
      progressValue: widget.processingProgress,
      processedObject: 'timeline trace',
    );
  }
}

// TODO(kenz): for sections with more than one row deep, add empty row for
// section label.

// TODO(kenz): make flame chart sections collapsible.

class TimelineFlameChart extends FlameChart<TimelineData, TimelineEvent> {
  TimelineFlameChart(
    TimelineData data, {
    @required double width,
    @required double height,
    @required ValueListenable<TimelineEvent> selectionNotifier,
    @required ValueListenable<List<TimelineEvent>> searchMatchesNotifier,
    @required ValueListenable<TimelineEvent> activeSearchMatchNotifier,
    @required Function(TimelineEvent event) onSelection,
  }) : super(
          data,
          time: data.time,
          containerWidth: width,
          containerHeight: height,
          startInset: _calculateStartInset(data),
          selectionNotifier: selectionNotifier,
          searchMatchesNotifier: searchMatchesNotifier,
          activeSearchMatchNotifier: activeSearchMatchNotifier,
          onSelected: onSelection,
        );

  static double _calculateStartInset(TimelineData data) {
    const spaceFor0msText = 55.0;
    const maxStartInset = 300.0;
    var maxMeasuredWidth = 0.0;
    for (String groupName in data.eventGroups.keys) {
      final textPainter = TextPainter(
        text: TextSpan(text: groupName),
        textDirection: TextDirection.ltr,
      )..layout();
      maxMeasuredWidth =
          math.max(maxMeasuredWidth, textPainter.width + 2 * densePadding);
    }
    return math.min(maxStartInset, maxMeasuredWidth) + spaceFor0msText;
  }

  /// Offset for drawing async guidelines.
  static const int asyncGuidelineOffset = 1;

  // Rows of top padding needed to create room for the timestamp labels and the
  // selected frame brackets.
  static const int rowOffsetForTopPadding = 3;

  @override
  TimelineFlameChartState createState() => TimelineFlameChartState();
}

class TimelineFlameChartState
    extends FlameChartState<TimelineFlameChart, TimelineEvent> {
  /// Stores the [FlameChartNode] for each [TimelineEvent] in the chart.
  ///
  /// We need to be able to look up a [FlameChartNode] based on its
  /// corresponding [TimelineEvent] when we traverse the event tree.
  final chartNodesByEvent = <TimelineEvent, FlameChartNode>{};

  /// Async guideline segments drawn in the direction of the x-axis.
  final horizontalGuidelines = <HorizontalLineSegment>[];

  /// Async guideline segments drawn in the direction of the y-axis.
  final verticalGuidelines = <VerticalLineSegment>[];

  final eventGroupStartYValues = Expando<double>();

  int widestRow = -1;

  TimelineController _timelineController;

  TimelineFrame _selectedFrame;

  @override
  int get rowOffsetForTopPadding => TimelineFlameChart.rowOffsetForTopPadding;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    final newController = Provider.of<TimelineController>(context);
    if (newController == _timelineController) return;
    _timelineController = newController;

    addAutoDisposeListener(
      _timelineController.selectedFrame,
      _handleSelectedFrame,
    );

    addAutoDisposeListener(_timelineController.activeSearchMatch, () async {
      final activeSearch = _timelineController.activeSearchMatch.value;
      if (activeSearch == null) return;

      // Ensure the [activeSearch] is vertically in view.
      if (!_isEventVerticallyInView(activeSearch)) {
        await scrollVerticallyToEvent(activeSearch);
      }

      // TODO(kenz): zoom if the event is less than some min width.

      // Ensure the [activeSearch] is horizontally in view.
      if (!_isEventHorizontallyInView(activeSearch)) {
        await scrollHorizontallyToTime(
            timeMicros: activeSearch.time.start.inMicroseconds);
      }
    });
  }

  bool _isEventVerticallyInView(TimelineEvent event) {
    final eventTopY = _topYForEvent(event);
    return eventTopY > verticalScrollOffset &&
        eventTopY + rowHeightWithPadding <
            verticalScrollOffset + widget.containerHeight;
  }

  bool _isEventHorizontallyInView(TimelineEvent event) {
    return visibleTimeRange.contains(event.time.start) &&
        visibleTimeRange.contains(event.time.end);
  }

  double _topYForEvent(TimelineEvent event) {
    final eventGroup = widget.data.eventGroups[computeEventGroupKey(event)];
    assert(eventGroup != null);
    final rowOffsetInGroup = eventGroup.rowIndexForEvent[event];
    return eventGroupStartYValues[eventGroup] +
        rowOffsetInGroup * rowHeightWithPadding;
  }

  void _handleSelectedFrame() async {
    final TimelineFrame selectedFrame = _timelineController.selectedFrame.value;
    if (selectedFrame != null) {
      if (selectedFrame == _selectedFrame) return;

      setState(() {
        _selectedFrame = selectedFrame;
      });

      // TODO(kenz): consider using jumpTo for some of these animations to
      // improve performance.

      // Vertically scroll to the frame's UI event.
      await scrollVerticallyToEvent(selectedFrame.uiEventFlow);

      // Bail early if the selection has changed again while the animation was
      // in progress.
      if (selectedFrame != _selectedFrame) return;

      // Zoom the frame into view.
      await zoomToTimeRange(
        startMicros: selectedFrame.time.start.inMicroseconds,
        durationMicros: selectedFrame.time.duration.inMicroseconds,
      );

      // Bail early if the selection has changed again while the animation was
      // in progress.
      if (selectedFrame != _selectedFrame) return;

      // Horizontally scroll to the frame.
      await scrollHorizontallyToTime(
        timeMicros: selectedFrame.time.start.inMicroseconds,
      );
    }
  }

  Future<void> zoomToTimeRange({
    @required int startMicros,
    @required int durationMicros,
    double targetWidth,
  }) async {
    targetWidth ??= widget.containerWidth * 0.8;
    final startingWidth = durationMicros * startingPxPerMicro;
    final zoom = targetWidth / startingWidth;
    final mouseXForZoom = (startMicros - startTimeOffset + durationMicros / 2) *
            startingPxPerMicro +
        widget.startInset;
    await zoomTo(zoom, forceMouseX: mouseXForZoom);
  }

  Future<void> scrollHorizontallyToTime({@required int timeMicros}) async {
    // Horizontally scroll to the frame.
    final relativeStartTime = timeMicros - startTimeOffset;
    final ratio = relativeStartTime / widget.data.time.duration.inMicroseconds;
    final offset = contentWidthWithZoom * ratio +
        widget.startInset -
        widget.containerWidth * 0.1;
    await scrollToX(offset);
  }

  Future<void> scrollVerticallyToEvent(TimelineEvent event) async {
    await verticalScrollController.animateTo(
      // Subtract [2 * rowHeightWithPadding] to give the target scroll event top padding.
      _topYForEvent(event) - 2 * rowHeightWithPadding,
      duration: shortDuration,
      curve: defaultCurve,
    );
  }

  @override
  void initFlameChartElements() {
    super.initFlameChartElements();

    double leftForEvent(TimelineEvent event) {
      return (event.time.start.inMicroseconds - startTimeOffset) *
              startingPxPerMicro +
          widget.startInset;
    }

    double rightForEvent(TimelineEvent event) {
      return (event.time.end.inMicroseconds - startTimeOffset) *
              startingPxPerMicro +
          widget.startInset;
    }

    double maxRight = -1;
    void createChartNode(TimelineEvent event, int row, int section) {
      // TODO(kenz): we should do something more clever here by inferring the
      // missing start/end time based on ancestors/children. Skip for now.
      if (!event.isWellFormed) return;

      final double left = leftForEvent(event);
      final double right = rightForEvent(event);
      if (right > maxRight) {
        maxRight = right;
        widestRow = row;
      }

      Color backgroundColor;
      if (event.isAsyncEvent) {
        backgroundColor = nextAsyncColor(row);
      } else if (event.isUiEvent) {
        backgroundColor = nextUiColor(row);
      } else if (event.isRasterEvent) {
        backgroundColor = nextRasterColor(row);
      } else {
        backgroundColor = nextUnknownColor(row);
      }

      Color textColor;
      if (event.isRasterEvent) {
        textColor = contrastForegroundWhite;
      } else {
        textColor = Colors.black;
      }

      final node = FlameChartNode<TimelineEvent>(
        key: Key('${event.name} ${event.traceEvents.first.id}'),
        text: event.name,
        tooltip: '${event.name} - ${msText(event.time.duration)}',
        rect: Rect.fromLTRB(left, flameChartNodeTop, right, rowHeight),
        backgroundColor: backgroundColor,
        textColor: textColor,
        data: event,
        onSelected: (dynamic event) => widget.onSelected(event),
        sectionIndex: section,
      );
      chartNodesByEvent[event] = node;

      rows[row].addNode(node);
    }

    expandRows(rowOffsetForTopPadding);
    int currentRowIndex = rowOffsetForTopPadding;
    int currentSectionIndex = 0;
    double yOffset = rowOffsetForTopPadding * sectionSpacing;
    for (String groupName in widget.data.eventGroups.keys) {
      final TimelineEventGroup group = widget.data.eventGroups[groupName];
      // Expand rows to fit nodes in [group].
      assert(rows.length == currentRowIndex);
      expandRows(rows.length + group.displaySize);
      eventGroupStartYValues[group] = yOffset;
      for (int i = 0; i < group.rows.length; i++) {
        for (var event in group.rows[i].events) {
          createChartNode(
            event,
            currentRowIndex + i,
            currentSectionIndex,
          );
        }
      }

      final section = FlameChartSection(
        currentSectionIndex,
        startRow: currentRowIndex,
        endRow: currentRowIndex + group.displayDepth,
      );
      sections.add(section);

      // Increment for next section.
      currentRowIndex += group.displaySize;
      currentSectionIndex++;
      yOffset += group.displaySizePx;
    }

    // Ensure the nodes in each row are sorted in ascending positional order.
    for (var row in rows) {
      row.nodes.sort((a, b) => a.rect.left.compareTo(b.rect.left));
    }

    _calculateAsyncGuidelines();
  }

  @override
  List<CustomPaint> buildCustomPaints(
    BoxConstraints constraints,
    BuildContext buildContext,
  ) {
    final colorScheme = Theme.of(context).colorScheme;
    final zoom = zoomController.value;
    return [
      CustomPaint(
        painter: AsyncGuidelinePainter(
          zoom: zoom,
          constraints: constraints,
          verticalScrollOffset: verticalScrollOffset,
          horizontalScrollOffset: horizontalScrollOffset,
          verticalGuidelines: verticalGuidelines,
          horizontalGuidelines: horizontalGuidelines,
          chartStartInset: widget.startInset,
          colorScheme: colorScheme,
        ),
      ),
      CustomPaint(
        painter: TimelineGridPainter(
          zoom: zoom,
          constraints: constraints,
          verticalScrollOffset: verticalScrollOffset,
          horizontalScrollOffset: horizontalScrollOffset,
          chartStartInset: widget.startInset,
          chartEndInset: widget.endInset,
          flameChartWidth: widthWithZoom,
          duration: widget.time.duration,
          colorScheme: colorScheme,
        ),
      ),
      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,
          colorScheme: colorScheme,
        ),
      ),
      CustomPaint(
        painter: SectionLabelPainter(
          _timelineController.data.eventGroups,
          zoom: zoom,
          constraints: constraints,
          verticalScrollOffset: verticalScrollOffset,
          horizontalScrollOffset: horizontalScrollOffset,
          chartStartInset: widget.startInset,
          colorScheme: colorScheme,
        ),
      ),
    ];
  }

  void _calculateAsyncGuidelines() {
    // Padding to be added between a subsequent guideline and the child event
    // it is connecting.
    const subsequentChildGuidelinePadding = 8.0;
    assert(rows.isNotEmpty);
    assert(chartNodesByEvent.isNotEmpty);
    verticalGuidelines.clear();
    horizontalGuidelines.clear();
    for (var row in rows) {
      for (var node in row.nodes) {
        if (node.data is AsyncTimelineEvent) {
          final event = node.data as AsyncTimelineEvent;
          bool allChildrenAreAsyncInstantEvents = true;
          for (var child in event.children) {
            if (!child.isAsyncInstantEvent) {
              allChildrenAreAsyncInstantEvents = false;
              break;
            }
          }
          // Continue if there are no children we should draw async guidelines
          // to.
          if (event.children.isEmpty || allChildrenAreAsyncInstantEvents) {
            continue;
          }

          // Vertical guideline that will connect [node] with its children
          // nodes. The line will end at [node]'s last child.
          final verticalGuidelineX =
              node.rect.left + TimelineFlameChart.asyncGuidelineOffset;
          final verticalGuidelineStartY =
              _calculateVerticalGuidelineStartY(event);
          final verticalGuidelineEndY =
              _calculateHorizontalGuidelineY(event.lowestDisplayChild);
          verticalGuidelines.add(VerticalLineSegment(
            Offset(verticalGuidelineX, verticalGuidelineStartY),
            Offset(verticalGuidelineX, verticalGuidelineEndY),
          ));

          // Draw the first child since it is guaranteed to be connected to
          // the main vertical we just created.
          final firstChild = event.children.first;
          final horizontalGuidelineEndX =
              chartNodesByEvent[firstChild].rect.left;
          final horizontalGuidelineY =
              _calculateHorizontalGuidelineY(firstChild);
          horizontalGuidelines.add(HorizontalLineSegment(
            Offset(verticalGuidelineX, horizontalGuidelineY),
            Offset(horizontalGuidelineEndX, horizontalGuidelineY),
          ));

          // Horizontal guidelines connecting each child to the vertical
          // guideline above.
          for (int i = 1; i < event.children.length; i++) {
            double horizontalGuidelineStartX = verticalGuidelineX;

            final child = event.children[i];
            final childNode = chartNodesByEvent[child];

            // Helper method to generate a vertical guideline for subsequent
            // children after the first child. We will create a new guideline
            // if it can be created without intersecting previous children.
            void generateSubsequentVerticalGuideline(double previousXInRow) {
              double newVerticalGuidelineX;

              // If [child] started after [event] ended, use the right edge of
              // event's [node] as the x coordinate for the guideline.
              // Otherwise, take the minimum of
              // [subsequentChildGuidelineOffset] and half the distance
              // between [previousXInRow] and child's left edge.
              if (event.time.end < child.time.start) {
                newVerticalGuidelineX = node.rect.right;
              } else {
                newVerticalGuidelineX = childNode.rect.left -
                    math.min(
                      subsequentChildGuidelinePadding,
                      (childNode.rect.left - previousXInRow) / 2,
                    );
              }
              final newVerticalGuidelineEndY =
                  _calculateHorizontalGuidelineY(child);
              verticalGuidelines.add(VerticalLineSegment(
                Offset(newVerticalGuidelineX, verticalGuidelineStartY),
                Offset(newVerticalGuidelineX, newVerticalGuidelineEndY),
              ));

              horizontalGuidelineStartX = newVerticalGuidelineX;
            }

            if (childNode.row.index == node.row.index + 1) {
              final previousChildIndex =
                  childNode.row.nodes.indexOf(childNode) - 1;
              final previousNode = childNode.row.nodes[previousChildIndex];
              generateSubsequentVerticalGuideline(previousNode.rect.right);
            }

            final horizontalGuidelineEndX = childNode.rect.left;
            final horizontalGuidelineY = _calculateHorizontalGuidelineY(child);
            horizontalGuidelines.add(HorizontalLineSegment(
              Offset(horizontalGuidelineStartX, horizontalGuidelineY),
              Offset(horizontalGuidelineEndX, horizontalGuidelineY),
            ));
          }
        }
      }
    }

    // Sort the lists in ascending order based on their cross axis coordinate.
    verticalGuidelines.sort();
    horizontalGuidelines.sort();
  }

  int _spacerRowsBeforeEvent(TimelineEvent event) {
    // Add 1 to account for the first spacer row before section 0 begins.
    return chartNodesByEvent[event].sectionIndex + rowOffsetForTopPadding;
  }<commit_msg>Fix timeline guideline rendering issue for async instant events (#2342)<commit_after>// 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.
import 'dart:collection';
import 'dart:math' as math;
import 'dart:ui';

import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

import '../auto_dispose_mixin.dart';
import '../charts/flame_chart.dart';
import '../common_widgets.dart';
import '../geometry.dart';
import '../theme.dart';
import '../ui/colors.dart';
import '../ui/search.dart';
import '../ui/theme.dart';
import '../utils.dart';
import 'timeline_controller.dart';
import 'timeline_model.dart';
import 'timeline_utils.dart';

final timelineSearchFieldKey = GlobalKey(debugLabel: 'TimelineSearchFieldKey');

class TimelineFlameChartContainer extends StatefulWidget {
  const TimelineFlameChartContainer({
    @required this.processing,
    @required this.processingProgress,
  });

  @visibleForTesting
  static const emptyTimelineKey = Key('Empty Timeline');

  final bool processing;

  final double processingProgress;

  @override
  _TimelineFlameChartContainerState createState() =>
      _TimelineFlameChartContainerState();
}

class _TimelineFlameChartContainerState
    extends State<TimelineFlameChartContainer>
    with AutoDisposeMixin, SearchFieldMixin {
  TimelineController controller;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();

    final newController = Provider.of<TimelineController>(context);
    if (newController == controller) return;
    controller = newController;
  }

  @override
  Widget build(BuildContext context) {
    Widget content;
    final timelineEmpty = (controller.data?.isEmpty ?? true) ||
        controller.data.eventGroups.isEmpty;
    if (widget.processing || timelineEmpty) {
      content = ValueListenableBuilder<bool>(
        valueListenable: controller.emptyTimeline,
        builder: (context, emptyRecording, _) {
          return emptyRecording
              ? const Center(
                  key: TimelineFlameChartContainer.emptyTimelineKey,
                  child: Text('No timeline events'),
                )
              : _buildProcessingInfo();
        },
      );
    } else {
      content = LayoutBuilder(
        builder: (context, constraints) {
          return TimelineFlameChart(
            controller.data,
            width: constraints.maxWidth,
            height: constraints.maxHeight,
            selectionNotifier: controller.selectedTimelineEvent,
            searchMatchesNotifier: controller.searchMatches,
            activeSearchMatchNotifier: controller.activeSearchMatch,
            onSelection: (e) => controller.selectTimelineEvent(e),
          );
        },
      );
    }

    final searchFieldEnabled =
        !(controller.data?.isEmpty ?? true) && !widget.processing;
    return OutlineDecoration(
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.end,
        children: [
          areaPaneHeader(
            context,
            title: 'Timeline Events',
            tall: true,
            needsTopBorder: false,
            actions: [
              Container(
                width: wideSearchTextWidth,
                height: defaultSearchTextHeight,
                child: buildSearchField(
                  controller: controller,
                  searchFieldKey: timelineSearchFieldKey,
                  searchFieldEnabled: searchFieldEnabled,
                  shouldRequestFocus: searchFieldEnabled,
                  supportsNavigation: true,
                ),
              ),
            ],
          ),
          Expanded(
            child: Padding(
              padding: const EdgeInsets.only(bottom: 8.0),
              child: content,
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildProcessingInfo() {
    return processingInfo(
      progressValue: widget.processingProgress,
      processedObject: 'timeline trace',
    );
  }
}

// TODO(kenz): for sections with more than one row deep, add empty row for
// section label.

// TODO(kenz): make flame chart sections collapsible.

class TimelineFlameChart extends FlameChart<TimelineData, TimelineEvent> {
  TimelineFlameChart(
    TimelineData data, {
    @required double width,
    @required double height,
    @required ValueListenable<TimelineEvent> selectionNotifier,
    @required ValueListenable<List<TimelineEvent>> searchMatchesNotifier,
    @required ValueListenable<TimelineEvent> activeSearchMatchNotifier,
    @required Function(TimelineEvent event) onSelection,
  }) : super(
          data,
          time: data.time,
          containerWidth: width,
          containerHeight: height,
          startInset: _calculateStartInset(data),
          selectionNotifier: selectionNotifier,
          searchMatchesNotifier: searchMatchesNotifier,
          activeSearchMatchNotifier: activeSearchMatchNotifier,
          onSelected: onSelection,
        );

  static double _calculateStartInset(TimelineData data) {
    const spaceFor0msText = 55.0;
    const maxStartInset = 300.0;
    var maxMeasuredWidth = 0.0;
    for (String groupName in data.eventGroups.keys) {
      final textPainter = TextPainter(
        text: TextSpan(text: groupName),
        textDirection: TextDirection.ltr,
      )..layout();
      maxMeasuredWidth =
          math.max(maxMeasuredWidth, textPainter.width + 2 * densePadding);
    }
    return math.min(maxStartInset, maxMeasuredWidth) + spaceFor0msText;
  }

  /// Offset for drawing async guidelines.
  static const int asyncGuidelineOffset = 1;

  // Rows of top padding needed to create room for the timestamp labels and the
  // selected frame brackets.
  static const int rowOffsetForTopPadding = 3;

  @override
  TimelineFlameChartState createState() => TimelineFlameChartState();
}

class TimelineFlameChartState
    extends FlameChartState<TimelineFlameChart, TimelineEvent> {
  /// Stores the [FlameChartNode] for each [TimelineEvent] in the chart.
  ///
  /// We need to be able to look up a [FlameChartNode] based on its
  /// corresponding [TimelineEvent] when we traverse the event tree.
  final chartNodesByEvent = <TimelineEvent, FlameChartNode>{};

  /// Async guideline segments drawn in the direction of the x-axis.
  final horizontalGuidelines = <HorizontalLineSegment>[];

  /// Async guideline segments drawn in the direction of the y-axis.
  final verticalGuidelines = <VerticalLineSegment>[];

  final eventGroupStartYValues = Expando<double>();

  int widestRow = -1;

  TimelineController _timelineController;

  TimelineFrame _selectedFrame;

  @override
  int get rowOffsetForTopPadding => TimelineFlameChart.rowOffsetForTopPadding;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    final newController = Provider.of<TimelineController>(context);
    if (newController == _timelineController) return;
    _timelineController = newController;

    addAutoDisposeListener(
      _timelineController.selectedFrame,
      _handleSelectedFrame,
    );

    addAutoDisposeListener(_timelineController.activeSearchMatch, () async {
      final activeSearch = _timelineController.activeSearchMatch.value;
      if (activeSearch == null) return;

      // Ensure the [activeSearch] is vertically in view.
      if (!_isEventVerticallyInView(activeSearch)) {
        await scrollVerticallyToEvent(activeSearch);
      }

      // TODO(kenz): zoom if the event is less than some min width.

      // Ensure the [activeSearch] is horizontally in view.
      if (!_isEventHorizontallyInView(activeSearch)) {
        await scrollHorizontallyToTime(
            timeMicros: activeSearch.time.start.inMicroseconds);
      }
    });
  }

  bool _isEventVerticallyInView(TimelineEvent event) {
    final eventTopY = _topYForEvent(event);
    return eventTopY > verticalScrollOffset &&
        eventTopY + rowHeightWithPadding <
            verticalScrollOffset + widget.containerHeight;
  }

  bool _isEventHorizontallyInView(TimelineEvent event) {
    return visibleTimeRange.contains(event.time.start) &&
        visibleTimeRange.contains(event.time.end);
  }

  double _topYForEvent(TimelineEvent event) {
    final eventGroup = widget.data.eventGroups[computeEventGroupKey(event)];
    assert(eventGroup != null);
    final rowOffsetInGroup = eventGroup.rowIndexForEvent[event];
    return eventGroupStartYValues[eventGroup] +
        rowOffsetInGroup * rowHeightWithPadding;
  }

  void _handleSelectedFrame() async {
    final TimelineFrame selectedFrame = _timelineController.selectedFrame.value;
    if (selectedFrame != null) {
      if (selectedFrame == _selectedFrame) return;

      setState(() {
        _selectedFrame = selectedFrame;
      });

      // TODO(kenz): consider using jumpTo for some of these animations to
      // improve performance.

      // Vertically scroll to the frame's UI event.
      await scrollVerticallyToEvent(selectedFrame.uiEventFlow);

      // Bail early if the selection has changed again while the animation was
      // in progress.
      if (selectedFrame != _selectedFrame) return;

      // Zoom the frame into view.
      await zoomToTimeRange(
        startMicros: selectedFrame.time.start.inMicroseconds,
        durationMicros: selectedFrame.time.duration.inMicroseconds,
      );

      // Bail early if the selection has changed again while the animation was
      // in progress.
      if (selectedFrame != _selectedFrame) return;

      // Horizontally scroll to the frame.
      await scrollHorizontallyToTime(
        timeMicros: selectedFrame.time.start.inMicroseconds,
      );
    }
  }

  Future<void> zoomToTimeRange({
    @required int startMicros,
    @required int durationMicros,
    double targetWidth,
  }) async {
    targetWidth ??= widget.containerWidth * 0.8;
    final startingWidth = durationMicros * startingPxPerMicro;
    final zoom = targetWidth / startingWidth;
    final mouseXForZoom = (startMicros - startTimeOffset + durationMicros / 2) *
            startingPxPerMicro +
        widget.startInset;
    await zoomTo(zoom, forceMouseX: mouseXForZoom);
  }

  Future<void> scrollHorizontallyToTime({@required int timeMicros}) async {
    // Horizontally scroll to the frame.
    final relativeStartTime = timeMicros - startTimeOffset;
    final ratio = relativeStartTime / widget.data.time.duration.inMicroseconds;
    final offset = contentWidthWithZoom * ratio +
        widget.startInset -
        widget.containerWidth * 0.1;
    await scrollToX(offset);
  }

  Future<void> scrollVerticallyToEvent(TimelineEvent event) async {
    await verticalScrollController.animateTo(
      // Subtract [2 * rowHeightWithPadding] to give the target scroll event top padding.
      _topYForEvent(event) - 2 * rowHeightWithPadding,
      duration: shortDuration,
      curve: defaultCurve,
    );
  }

  @override
  void initFlameChartElements() {
    super.initFlameChartElements();

    double leftForEvent(TimelineEvent event) {
      return (event.time.start.inMicroseconds - startTimeOffset) *
              startingPxPerMicro +
          widget.startInset;
    }

    double rightForEvent(TimelineEvent event) {
      return (event.time.end.inMicroseconds - startTimeOffset) *
              startingPxPerMicro +
          widget.startInset;
    }

    double maxRight = -1;
    void createChartNode(TimelineEvent event, int row, int section) {
      // TODO(kenz): we should do something more clever here by inferring the
      // missing start/end time based on ancestors/children. Skip for now.
      if (!event.isWellFormed) return;

      final double left = leftForEvent(event);
      final double right = rightForEvent(event);
      if (right > maxRight) {
        maxRight = right;
        widestRow = row;
      }

      Color backgroundColor;
      if (event.isAsyncEvent) {
        backgroundColor = nextAsyncColor(row);
      } else if (event.isUiEvent) {
        backgroundColor = nextUiColor(row);
      } else if (event.isRasterEvent) {
        backgroundColor = nextRasterColor(row);
      } else {
        backgroundColor = nextUnknownColor(row);
      }

      Color textColor;
      if (event.isRasterEvent) {
        textColor = contrastForegroundWhite;
      } else {
        textColor = Colors.black;
      }

      final node = FlameChartNode<TimelineEvent>(
        key: Key('${event.name} ${event.traceEvents.first.id}'),
        text: event.name,
        tooltip: '${event.name} - ${msText(event.time.duration)}',
        rect: Rect.fromLTRB(left, flameChartNodeTop, right, rowHeight),
        backgroundColor: backgroundColor,
        textColor: textColor,
        data: event,
        onSelected: (dynamic event) => widget.onSelected(event),
        sectionIndex: section,
      );
      chartNodesByEvent[event] = node;

      rows[row].addNode(node);
    }

    expandRows(rowOffsetForTopPadding);
    int currentRowIndex = rowOffsetForTopPadding;
    int currentSectionIndex = 0;
    double yOffset = rowOffsetForTopPadding * sectionSpacing;
    for (String groupName in widget.data.eventGroups.keys) {
      final TimelineEventGroup group = widget.data.eventGroups[groupName];
      // Expand rows to fit nodes in [group].
      assert(rows.length == currentRowIndex);
      expandRows(rows.length + group.displaySize);
      eventGroupStartYValues[group] = yOffset;
      for (int i = 0; i < group.rows.length; i++) {
        for (var event in group.rows[i].events) {
          createChartNode(
            event,
            currentRowIndex + i,
            currentSectionIndex,
          );
        }
      }

      final section = FlameChartSection(
        currentSectionIndex,
        startRow: currentRowIndex,
        endRow: currentRowIndex + group.displayDepth,
      );
      sections.add(section);

      // Increment for next section.
      currentRowIndex += group.displaySize;
      currentSectionIndex++;
      yOffset += group.displaySizePx;
    }

    // Ensure the nodes in each row are sorted in ascending positional order.
    for (var row in rows) {
      row.nodes.sort((a, b) => a.rect.left.compareTo(b.rect.left));
    }

    _calculateAsyncGuidelines();
  }

  @override
  List<CustomPaint> buildCustomPaints(
    BoxConstraints constraints,
    BuildContext buildContext,
  ) {
    final colorScheme = Theme.of(context).colorScheme;
    final zoom = zoomController.value;
    return [
      CustomPaint(
        painter: AsyncGuidelinePainter(
          zoom: zoom,
          constraints: constraints,
          verticalScrollOffset: verticalScrollOffset,
          horizontalScrollOffset: horizontalScrollOffset,
          verticalGuidelines: verticalGuidelines,
          horizontalGuidelines: horizontalGuidelines,
          chartStartInset: widget.startInset,
          colorScheme: colorScheme,
        ),
      ),
      CustomPaint(
        painter: TimelineGridPainter(
          zoom: zoom,
          constraints: constraints,
          verticalScrollOffset: verticalScrollOffset,
          horizontalScrollOffset: horizontalScrollOffset,
          chartStartInset: widget.startInset,
          chartEndInset: widget.endInset,
          flameChartWidth: widthWithZoom,
          duration: widget.time.duration,
          colorScheme: colorScheme,
        ),
      ),
      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,
          colorScheme: colorScheme,
        ),
      ),
      CustomPaint(
        painter: SectionLabelPainter(
          _timelineController.data.eventGroups,
          zoom: zoom,
          constraints: constraints,
          verticalScrollOffset: verticalScrollOffset,
          horizontalScrollOffset: horizontalScrollOffset,
          chartStartInset: widget.startInset,
          colorScheme: colorScheme,
        ),
      ),
    ];
  }

  void _calculateAsyncGuidelines() {
    // Padding to be added between a subsequent guideline and the child event
    // it is connecting.
    const subsequentChildGuidelinePadding = 8.0;
    assert(rows.isNotEmpty);
    assert(chartNodesByEvent.isNotEmpty);
    verticalGuidelines.clear();
    horizontalGuidelines.clear();
    for (var row in rows) {
      for (var node in row.nodes) {
        if (node.data is AsyncTimelineEvent) {
          final event = node.data as AsyncTimelineEvent;
          bool allChildrenAreAsyncInstantEvents = true;
          for (var child in event.children) {
            if (!child.isAsyncInstantEvent) {
              allChildrenAreAsyncInstantEvents = false;
              break;
            }
          }
          // Continue if there are no children we should draw async guidelines
          // to.
          if (event.children.isEmpty || allChildrenAreAsyncInstantEvents) {
            continue;
          }

          // Vertical guideline that will connect [node] with its children
          // nodes. The line will end at [node]'s last child.
          final verticalGuidelineX =
              node.rect.left + TimelineFlameChart.asyncGuidelineOffset;
          final verticalGuidelineStartY =
              _calculateVerticalGuidelineStartY(event);
          final verticalGuidelineEndY =
              _calculateHorizontalGuidelineY(event.lowestDisplayChild);
          verticalGuidelines.add(VerticalLineSegment(
            Offset(verticalGuidelineX, verticalGuidelineStartY),
            Offset(verticalGuidelineX, verticalGuidelineEndY),
          ));

          // Draw the horizontal guideline to the first child event that is not
          // an instant event, since it is guaranteed to be connected to
          // the main vertical we just created.
          final firstChild = event.children
              .firstWhere((TimelineEvent e) => !e.isAsyncInstantEvent);
          final horizontalGuidelineEndX =
              chartNodesByEvent[firstChild].rect.left;
          final horizontalGuidelineY =
              _calculateHorizontalGuidelineY(firstChild);
          horizontalGuidelines.add(HorizontalLineSegment(
            Offset(verticalGuidelineX, horizontalGuidelineY),
            Offset(horizontalGuidelineEndX, horizontalGuidelineY),
          ));

          // Horizontal guidelines connecting each child to the vertical
          // guideline above.
          for (int i = 1; i < event.children.length; i++) {
            double horizontalGuidelineStartX = verticalGuidelineX;

            final child = event.children[i];
            if (child.isAsyncInstantEvent) continue;

            final childNode = chartNodesByEvent[child];

            // Helper method to generate a vertical guideline for subsequent
            // children after the first child. We will create a new guideline
            // if it can be created without intersecting previous children.
            void generateSubsequentVerticalGuideline(double previousXInRow) {
              double newVerticalGuidelineX;

              // If [child] started after [event] ended, use the right edge of
              // event's [node] as the x coordinate for the guideline.
              // Otherwise, take the minimum of
              // [subsequentChildGuidelineOffset] and half the distance
              // between [previousXInRow] and child's left edge.
              if (event.time.end < child.time.start) {
                newVerticalGuidelineX = node.rect.right;
              } else {
                newVerticalGuidelineX = childNode.rect.left -
                    math.min(
                      subsequentChildGuidelinePadding,
                      (childNode.rect.left - previousXInRow) / 2,
                    );
              }
              final newVerticalGuidelineEndY =
                  _calculateHorizontalGuidelineY(child);
              verticalGuidelines.add(VerticalLineSegment(
                Offset(newVerticalGuidelineX, verticalGuidelineStartY),
                Offset(newVerticalGuidelineX, newVerticalGuidelineEndY),
              ));

              horizontalGuidelineStartX = newVerticalGuidelineX;
            }

            FlameChartNode previousSiblingInRow(FlameChartNode child) {
              final previousNodeIndex = child.row.nodes.indexOf(child) - 1;
              final previousNode = previousNodeIndex >= 0
                  ? child.row.nodes[previousNodeIndex]
                  : null;
              final isSibling = previousNode?.data?.parent == node.data;
              return isSibling ? previousNode : null;
            }

            if (childNode.row.index == node.row.index + 1) {
              FlameChartNode previousSibling = previousSiblingInRow(childNode);
              // Look back until we find the first sibling in this row that is
              // not an instant event (if present).
              while (previousSibling != null &&
                  (previousSibling.data as AsyncTimelineEvent)
                      .isAsyncInstantEvent) {
                previousSibling = previousSiblingInRow(previousSibling);
              }
              if (previousSibling != null) {
                generateSubsequentVerticalGuideline(previousSibling.rect.right);
              }
            }

            final horizontalGuidelineEndX = childNode.rect.left;
            final horizontalGuidelineY = _calculateHorizontalGuidelineY(child);
            horizontalGuidelines.add(HorizontalLineSegment(
              Offset(horizontalGuidelineStartX, horizontalGuidelineY),
              Offset(horizontalGuidelineEndX, horizontalGuidelineY),
            ));
          }
        }
      }
    }

    // Sort the lists in ascending order based on their cross axis coordinate.
    verticalGuidelines.sort();
    horizontalGuidelines.sort();
  }

  int _spacerRowsBeforeEvent(TimelineEvent event) {
    // Add 1 to account for the first spacer row before section 0 begins.
    return chartNodesByEvent[event].sectionIndex + rowOffsetForTopPadding;
  }
{'fraction_non_alphanumeric': 0.08073861971005343, 'fraction_numerical': 0.0018713503032489348, 'mean_word_length': 2.565147496181979, 'pattern_counts': {'":': 0, '<': 49, '<?xml version=': 0, '>': 61, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '15265', 'n_tokens_mistral': 11715, 'n_tokens_neox': 11363, 'n_words': 2820}