• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright (C) 2024 The Android Open Source Project
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15/**
16 * This module provides the TrackNodeTree mithril component, which is
17 * responsible for rendering out a tree of tracks and drawing their content
18 * onto the canvas.
19 * - Rendering track panels and handling nested and sticky headers.
20 * - Managing the virtual canvas & drawing the grid-lines, tracks and overlays
21 *   onto the canvas.
22 * - Handling track interaction events such as dragging, panning and scrolling.
23 */
24
25import m from 'mithril';
26import {canvasClip, canvasSave} from '../../base/canvas_utils';
27import {classNames} from '../../base/classnames';
28import {Bounds2D, Rect2D, Size2D, VerticalBounds} from '../../base/geom';
29import {HighPrecisionTimeSpan} from '../../base/high_precision_time_span';
30import {Icons} from '../../base/semantic_icons';
31import {TimeScale} from '../../base/time_scale';
32import {RequiredField} from '../../base/utils';
33import {PerfStats, runningStatStr} from '../../core/perf_stats';
34import {raf} from '../../core/raf_scheduler';
35import {TraceImpl} from '../../core/trace_impl';
36import {TrackWithFSM} from '../../core/track_manager';
37import {TrackRenderer, Track} from '../../public/track';
38import {TrackNode, Workspace} from '../../public/workspace';
39import {Button} from '../../widgets/button';
40import {MenuDivider, MenuItem, PopupMenu} from '../../widgets/menu';
41import {TrackShell} from '../../widgets/track_shell';
42import {Tree, TreeNode} from '../../widgets/tree';
43import {SELECTION_FILL_COLOR} from '../css_constants';
44import {calculateResolution} from './resolution';
45import {Trace} from '../../public/trace';
46import {Anchor} from '../../widgets/anchor';
47import {showModal} from '../../widgets/modal';
48import {copyToClipboard} from '../../base/clipboard';
49
50const TRACK_HEIGHT_MIN_PX = 18;
51const TRACK_HEIGHT_DEFAULT_PX = 30;
52
53function getTrackHeight(node: TrackNode, track?: TrackRenderer) {
54  // Headless tracks have an effective height of 0.
55  if (node.headless) return 0;
56
57  // Expanded summary tracks don't show any data, so make them a little more
58  // compact to save space.
59  if (node.isSummary && node.expanded) return TRACK_HEIGHT_DEFAULT_PX;
60
61  const trackHeight = track?.getHeight();
62  if (trackHeight === undefined) return TRACK_HEIGHT_DEFAULT_PX;
63
64  // Limit the minimum height of a track, and also round up to the nearest
65  // integer, as sub-integer DOM alignment can cause issues e.g. with sticky
66  // positioning.
67  return Math.ceil(Math.max(trackHeight, TRACK_HEIGHT_MIN_PX));
68}
69
70export interface TrackViewAttrs {
71  // Render a lighter version of this track view (for when tracks are offscreen).
72  readonly lite: boolean;
73  readonly scrollToOnCreate?: boolean;
74  readonly reorderable?: boolean;
75  readonly removable?: boolean;
76  readonly depth: number;
77  readonly stickyTop: number;
78  readonly collapsible: boolean;
79}
80
81/**
82 * The `TrackView` class is responsible for managing and rendering individual
83 * tracks in the `TrackTreeView` Mithril component. It handles operations such
84 * as:
85 *
86 * - Rendering track content in the DOM and virtual canvas.
87 * - Managing user interactions like dragging, panning, scrolling, and area
88 *   selection.
89 * - Tracking and displaying rendering performance metrics.
90 */
91export class TrackView {
92  readonly node: TrackNode;
93  readonly renderer?: TrackWithFSM;
94  readonly height: number;
95  readonly verticalBounds: VerticalBounds;
96
97  private readonly trace: TraceImpl;
98  private readonly descriptor?: Track;
99
100  constructor(trace: TraceImpl, node: TrackNode, top: number) {
101    this.trace = trace;
102    this.node = node;
103
104    if (node.uri) {
105      this.descriptor = trace.tracks.getTrack(node.uri);
106      this.renderer = this.trace.tracks.getTrackFSM(node.uri);
107    }
108
109    const heightPx = getTrackHeight(node, this.renderer?.track);
110    this.height = heightPx;
111    this.verticalBounds = {top, bottom: top + heightPx};
112  }
113
114  renderDOM(attrs: TrackViewAttrs, children: m.Children) {
115    const {
116      scrollToOnCreate,
117      reorderable = false,
118      collapsible,
119      removable,
120    } = attrs;
121    const {node, renderer, height} = this;
122
123    const buttons = attrs.lite
124      ? []
125      : [
126          renderer?.track.getTrackShellButtons?.(),
127          (removable || node.removable) && this.renderCloseButton(),
128          // We don't want summary tracks to be pinned as they rarely have
129          // useful information.
130          !node.isSummary && this.renderPinButton(),
131          this.renderTrackMenuButton(),
132          this.renderAreaSelectionCheckbox(),
133        ];
134
135    let scrollIntoView = false;
136    const tracks = this.trace.tracks;
137    if (tracks.scrollToTrackNodeId === node.id) {
138      tracks.scrollToTrackNodeId = undefined;
139      scrollIntoView = true;
140    }
141
142    function showTrackMoveErrorModal(msg: string) {
143      showModal({
144        title: 'Error',
145        content: msg,
146        buttons: [{text: 'OK'}],
147      });
148    }
149
150    return m(
151      TrackShell,
152      {
153        id: node.id,
154        title: node.title,
155        subtitle: renderer?.desc.subtitle,
156        ref: node.fullPath.join('/'),
157        heightPx: height,
158        error: renderer?.getError(),
159        chips: renderer?.desc.chips,
160        buttons,
161        scrollToOnCreate: scrollToOnCreate || scrollIntoView,
162        collapsible: collapsible && node.hasChildren,
163        collapsed: collapsible && node.collapsed,
164        highlight: this.isHighlighted(),
165        summary: node.isSummary,
166        reorderable,
167        depth: attrs.depth,
168        stickyTop: attrs.stickyTop,
169        pluginId: renderer?.desc.pluginId,
170        lite: attrs.lite,
171        onCollapsedChanged: () => {
172          node.hasChildren && node.toggleCollapsed();
173        },
174        onTrackContentMouseMove: (pos, bounds) => {
175          const timescale = this.getTimescaleForBounds(bounds);
176          renderer?.track.onMouseMove?.({
177            ...pos,
178            timescale,
179          });
180          raf.scheduleCanvasRedraw();
181        },
182        onTrackContentMouseOut: () => {
183          renderer?.track.onMouseOut?.();
184          raf.scheduleCanvasRedraw();
185        },
186        onTrackContentClick: (pos, bounds) => {
187          const timescale = this.getTimescaleForBounds(bounds);
188          raf.scheduleCanvasRedraw();
189          return (
190            renderer?.track.onMouseClick?.({
191              ...pos,
192              timescale,
193            }) ?? false
194          );
195        },
196        onupdate: () => {
197          renderer?.track.onFullRedraw?.();
198        },
199        onMoveBefore: (nodeId: string) => {
200          // We are the reference node (the one to be moved relative to), nodeId
201          // references the target node (the one to be moved)
202          const nodeToMove = node.workspace?.getTrackById(nodeId);
203          const targetNode = this.node.parent;
204          if (nodeToMove && targetNode) {
205            // Insert the target node before this one
206            const result = targetNode.addChildBefore(nodeToMove, node);
207            if (!result.ok) {
208              showTrackMoveErrorModal(result.error);
209            }
210          }
211        },
212        onMoveInside: (nodeId: string) => {
213          // This one moves the node inside this node & expand it if it's not
214          // expanded already.
215          const nodeToMove = node.workspace?.getTrackById(nodeId);
216          if (nodeToMove) {
217            const result = this.node.addChildLast(nodeToMove);
218            if (result.ok) {
219              this.node.expand();
220            } else {
221              showTrackMoveErrorModal(result.error);
222            }
223          }
224        },
225        onMoveAfter: (nodeId: string) => {
226          // We are the reference node (the one to be moved relative to), nodeId
227          // references the target node (the one to be moved)
228          const nodeToMove = node.workspace?.getTrackById(nodeId);
229          const targetNode = this.node.parent;
230          if (nodeToMove && targetNode) {
231            // Insert the target node after this one
232            const result = targetNode.addChildAfter(nodeToMove, node);
233            if (!result.ok) {
234              showTrackMoveErrorModal(result.error);
235            }
236          }
237        },
238      },
239      children,
240    );
241  }
242
243  drawCanvas(
244    ctx: CanvasRenderingContext2D,
245    rect: Rect2D,
246    visibleWindow: HighPrecisionTimeSpan,
247    perfStatsEnabled: boolean,
248    trackPerfStats: WeakMap<TrackNode, PerfStats>,
249  ) {
250    // For each track we rendered in view(), render it to the canvas. We know the
251    // vertical bounds, so we just need to combine it with the horizontal bounds
252    // and we're golden.
253    const {node, renderer, verticalBounds} = this;
254
255    if (node.isSummary && node.expanded) return;
256    if (renderer?.getError()) return;
257
258    const trackRect = new Rect2D({
259      ...rect,
260      ...verticalBounds,
261    });
262
263    // Track renderers expect to start rendering at (0, 0), so we need to
264    // translate the canvas and create a new timescale.
265    using _ = canvasSave(ctx);
266    canvasClip(ctx, trackRect);
267    ctx.translate(trackRect.left, trackRect.top);
268
269    const timescale = new TimeScale(visibleWindow, {
270      left: 0,
271      right: trackRect.width,
272    });
273
274    const start = performance.now();
275
276    node.uri &&
277      renderer?.render({
278        trackUri: node.uri,
279        visibleWindow,
280        size: trackRect,
281        resolution: calculateResolution(visibleWindow, trackRect.width),
282        ctx,
283        timescale,
284      });
285
286    this.highlightIfTrackInAreaSelection(ctx, timescale, trackRect);
287
288    const renderTime = performance.now() - start;
289
290    if (!perfStatsEnabled) return;
291    this.updateAndRenderTrackPerfStats(
292      ctx,
293      trackRect,
294      renderTime,
295      trackPerfStats,
296    );
297  }
298
299  private renderCloseButton() {
300    return m(Button, {
301      // TODO(stevegolton): It probably makes sense to only show this button
302      // when hovered for consistency with the other buttons, but hiding this
303      // button currently breaks the tests as we wait for the buttons to become
304      // available, enabled and visible before clicking on them.
305      // className: 'pf-visible-on-hover',
306      onclick: () => {
307        this.node.remove();
308      },
309      icon: Icons.Close,
310      title: 'Remove track',
311      compact: true,
312    });
313  }
314
315  private renderPinButton(): m.Children {
316    const isPinned = this.node.isPinned;
317    return m(Button, {
318      className: classNames(!isPinned && 'pf-visible-on-hover'),
319      onclick: () => {
320        isPinned ? this.node.unpin() : this.node.pin();
321      },
322      icon: Icons.Pin,
323      iconFilled: isPinned,
324      title: isPinned ? 'Unpin' : 'Pin to top',
325      compact: true,
326    });
327  }
328
329  private renderTrackMenuButton(): m.Children {
330    return m(
331      PopupMenu,
332      {
333        trigger: m(Button, {
334          className: 'pf-visible-on-hover',
335          icon: 'more_vert',
336          compact: true,
337          title: 'Track options',
338        }),
339      },
340      // Putting these menu items inside a component means that view is only
341      // called when the popup is actually open, which can improve DOM
342      // render performance when we have thousands of tracks on screen.
343      m(TrackPopupMenu, {
344        trace: this.trace,
345        node: this.node,
346        descriptor: this.descriptor,
347      }),
348    );
349  }
350
351  private getTimescaleForBounds(bounds: Bounds2D) {
352    const timeWindow = this.trace.timeline.visibleWindow;
353    return new TimeScale(timeWindow, {
354      left: 0,
355      right: bounds.right - bounds.left,
356    });
357  }
358
359  private isHighlighted() {
360    const {trace, node} = this;
361    // The track should be highlighted if the current search result matches this
362    // track or one of its children.
363    const searchIndex = trace.search.resultIndex;
364    const searchResults = trace.search.searchResults;
365
366    if (searchIndex !== -1 && searchResults !== undefined) {
367      // using _ = autoTimer();
368      const uri = searchResults.trackUris[searchIndex];
369      // Highlight if this or any children match the search results
370      if (uri === node.uri || node.getTrackByUri(uri)) {
371        return true;
372      }
373    }
374
375    const curSelection = trace.selection;
376    if (
377      curSelection.selection.kind === 'track' &&
378      curSelection.selection.trackUri === node.uri
379    ) {
380      return true;
381    }
382
383    return false;
384  }
385
386  private renderAreaSelectionCheckbox(): m.Children {
387    const {trace, node} = this;
388    const selectionManager = trace.selection;
389    const selection = selectionManager.selection;
390    if (selection.kind === 'area') {
391      if (node.isSummary) {
392        const tracksWithUris = node.flatTracks.filter(
393          (t) => t.uri !== undefined,
394        ) as ReadonlyArray<RequiredField<TrackNode, 'uri'>>;
395
396        // Check if any nodes within are selected
397        const childTracksInSelection = tracksWithUris.map((t) =>
398          selection.trackUris.includes(t.uri),
399        );
400
401        function renderButton(icon: string, title: string) {
402          return m(Button, {
403            onclick: () => {
404              const uris = tracksWithUris.map((t) => t.uri);
405              selectionManager.toggleGroupAreaSelection(uris);
406            },
407            compact: true,
408            icon,
409            title,
410          });
411        }
412
413        if (childTracksInSelection.every((b) => b)) {
414          return renderButton(
415            Icons.Checkbox,
416            'Remove child tracks from selection',
417          );
418        } else if (childTracksInSelection.some((b) => b)) {
419          return renderButton(
420            Icons.IndeterminateCheckbox,
421            'Add remaining child tracks to selection',
422          );
423        } else {
424          return renderButton(
425            Icons.BlankCheckbox,
426            'Add child tracks to selection',
427          );
428        }
429      } else {
430        const nodeUri = node.uri;
431        if (nodeUri) {
432          return (
433            selection.kind === 'area' &&
434            m(Button, {
435              onclick: () => {
436                selectionManager.toggleTrackAreaSelection(nodeUri);
437              },
438              compact: true,
439              ...(selection.trackUris.includes(nodeUri)
440                ? {icon: Icons.Checkbox, title: 'Remove track'}
441                : {icon: Icons.BlankCheckbox, title: 'Add track to selection'}),
442            })
443          );
444        }
445      }
446    }
447    return undefined;
448  }
449
450  private highlightIfTrackInAreaSelection(
451    ctx: CanvasRenderingContext2D,
452    timescale: TimeScale,
453    size: Size2D,
454  ) {
455    const selection = this.trace.selection.selection;
456
457    if (selection.kind !== 'area') {
458      return;
459    }
460
461    let selected = false;
462    if (this.node.isSummary) {
463      // Summary tracks cannot themselves be area-selected. So, as a visual aid,
464      // if this track is a summary track and some of its children are in the
465      // area selecion, highlight this track as if it were in the area
466      // selection too.
467      selected = selection.trackUris.some((uri) =>
468        this.node.getTrackByUri(uri),
469      );
470    } else {
471      // For non-summary tracks, simply highlight this track if it's in the area
472      // selection.
473      if (this.node.uri !== undefined) {
474        selected = selection.trackUris.includes(this.node.uri);
475      }
476    }
477
478    if (selected) {
479      const selectedAreaDuration = selection.end - selection.start;
480      ctx.fillStyle = SELECTION_FILL_COLOR;
481      ctx.fillRect(
482        timescale.timeToPx(selection.start),
483        0,
484        timescale.durationToPx(selectedAreaDuration),
485        size.height,
486      );
487    }
488  }
489
490  private updateAndRenderTrackPerfStats(
491    ctx: CanvasRenderingContext2D,
492    size: Size2D,
493    renderTime: number,
494    trackPerfStats: WeakMap<TrackNode, PerfStats>,
495  ) {
496    let renderStats = trackPerfStats.get(this.node);
497    if (renderStats === undefined) {
498      renderStats = new PerfStats();
499      trackPerfStats.set(this.node, renderStats);
500    }
501    renderStats.addValue(renderTime);
502
503    // Draw a green box around the whole track
504    ctx.strokeStyle = 'rgba(69, 187, 73, 0.5)';
505    const lineWidth = 1;
506    ctx.lineWidth = lineWidth;
507    ctx.strokeRect(
508      lineWidth / 2,
509      lineWidth / 2,
510      size.width - lineWidth,
511      size.height - lineWidth,
512    );
513
514    const statW = 300;
515    ctx.font = '10px sans-serif';
516    ctx.textAlign = 'start';
517    ctx.textBaseline = 'alphabetic';
518    ctx.direction = 'inherit';
519    ctx.fillStyle = 'hsl(97, 100%, 96%)';
520    ctx.fillRect(size.width - statW, size.height - 20, statW, 20);
521    ctx.fillStyle = 'hsla(122, 77%, 22%)';
522    const statStr = `Track ${this.node.id} | ` + runningStatStr(renderStats);
523    ctx.fillText(statStr, size.width - statW, size.height - 10);
524  }
525}
526
527interface TrackPopupMenuAttrs {
528  readonly trace: Trace;
529  readonly node: TrackNode;
530  readonly descriptor?: Track;
531}
532
533// This component contains the track menu items which are displayed inside a
534// popup menu on each track. They're in a component to avoid having to render
535// them every single mithril cycle.
536const TrackPopupMenu = {
537  view({attrs}: m.Vnode<TrackPopupMenuAttrs>) {
538    return [
539      m(MenuItem, {
540        label: 'Select track',
541        disabled: !attrs.node.uri,
542        onclick: () => {
543          attrs.trace.selection.selectTrack(attrs.node.uri!);
544        },
545        title: attrs.node.uri
546          ? 'Select track'
547          : 'Track has no URI and cannot be selected',
548      }),
549      m(
550        MenuItem,
551        {label: 'Track details'},
552        renderTrackDetailsMenu(attrs.node, attrs.descriptor),
553      ),
554      m(MenuDivider),
555      m(
556        MenuItem,
557        {label: 'Copy to workspace'},
558        attrs.trace.workspaces.all.map((ws) =>
559          m(MenuItem, {
560            label: ws.title,
561            disabled: !ws.userEditable,
562            onclick: () => copyToWorkspace(attrs.trace, attrs.node, ws),
563          }),
564        ),
565        m(MenuDivider),
566        m(MenuItem, {
567          label: 'New workspace...',
568          onclick: () => copyToWorkspace(attrs.trace, attrs.node),
569        }),
570      ),
571      m(
572        MenuItem,
573        {label: 'Copy & switch to workspace'},
574        attrs.trace.workspaces.all.map((ws) =>
575          m(MenuItem, {
576            label: ws.title,
577            disabled: !ws.userEditable,
578            onclick: async () => {
579              copyToWorkspace(attrs.trace, attrs.node, ws);
580              attrs.trace.workspaces.switchWorkspace(ws);
581            },
582          }),
583        ),
584        m(MenuDivider),
585        m(MenuItem, {
586          label: 'New workspace...',
587          onclick: async () => {
588            const ws = copyToWorkspace(attrs.trace, attrs.node);
589            attrs.trace.workspaces.switchWorkspace(ws);
590          },
591        }),
592      ),
593    ];
594  },
595};
596
597function copyToWorkspace(trace: Trace, node: TrackNode, ws?: Workspace) {
598  // If no workspace provided, create a new one.
599  if (!ws) {
600    ws = trace.workspaces.createEmptyWorkspace('Untitled Workspace');
601  }
602  // Deep clone makes sure all group's content is also copied
603  const newNode = node.clone(true);
604  newNode.removable = true;
605  ws.addChildLast(newNode);
606  return ws;
607}
608
609function renderTrackDetailsMenu(node: TrackNode, descriptor?: Track) {
610  let parent = node.parent;
611  let fullPath: m.ChildArray = [node.title];
612  while (parent && parent instanceof TrackNode) {
613    fullPath = [parent.title, ' \u2023 ', ...fullPath];
614    parent = parent.parent;
615  }
616
617  const query = descriptor?.track.getDataset?.()?.query();
618
619  return m(
620    '.pf-track__track-details-popup',
621    m(
622      Tree,
623      m(TreeNode, {left: 'Track Node ID', right: node.id}),
624      m(TreeNode, {left: 'Collapsed', right: `${node.collapsed}`}),
625      m(TreeNode, {left: 'URI', right: node.uri}),
626      m(TreeNode, {
627        left: 'Is Summary Track',
628        right: `${node.isSummary}`,
629      }),
630      m(TreeNode, {
631        left: 'SortOrder',
632        right: node.sortOrder ?? '0 (undefined)',
633      }),
634      m(TreeNode, {left: 'Path', right: fullPath}),
635      m(TreeNode, {left: 'Title', right: node.title}),
636      m(TreeNode, {
637        left: 'Workspace',
638        right: node.workspace?.title ?? '[no workspace]',
639      }),
640      descriptor &&
641        m(TreeNode, {
642          left: 'Plugin ID',
643          right: descriptor.pluginId,
644        }),
645      query &&
646        m(TreeNode, {
647          left: 'Track Query',
648          right: m(
649            Anchor,
650            {
651              onclick: () => {
652                showModal({
653                  title: 'Query for track',
654                  content: m('pre', query),
655                  buttons: [
656                    {
657                      text: 'Copy to clipboard',
658                      action: () => copyToClipboard(query),
659                    },
660                  ],
661                });
662              },
663            },
664            'Show query',
665          ),
666        }),
667      descriptor &&
668        m(
669          TreeNode,
670          {left: 'Tags'},
671          descriptor.tags &&
672            Object.entries(descriptor.tags).map(([key, value]) => {
673              return m(TreeNode, {left: key, right: value?.toString()});
674            }),
675        ),
676    ),
677  );
678}
679