xref: /aosp_15_r20/external/perfetto/ui/src/frontend/track_panel.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
1// Copyright (C) 2018 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
15import m from 'mithril';
16import {canvasClip, canvasSave} from '../base/canvas_utils';
17import {classNames} from '../base/classnames';
18import {Bounds2D, Size2D, VerticalBounds} from '../base/geom';
19import {Icons} from '../base/semantic_icons';
20import {TimeScale} from '../base/time_scale';
21import {RequiredField} from '../base/utils';
22import {calculateResolution} from '../common/resolution';
23import {featureFlags} from '../core/feature_flags';
24import {TrackRenderer} from '../core/track_manager';
25import {TrackDescriptor, TrackRenderContext} from '../public/track';
26import {TrackNode} from '../public/workspace';
27import {Button} from '../widgets/button';
28import {Popup, PopupPosition} from '../widgets/popup';
29import {Tree, TreeNode} from '../widgets/tree';
30import {SELECTION_FILL_COLOR, TRACK_SHELL_WIDTH} from './css_constants';
31import {Panel} from './panel_container';
32import {TrackWidget} from '../widgets/track_widget';
33import {raf} from '../core/raf_scheduler';
34import {Intent} from '../widgets/common';
35import {TraceImpl} from '../core/trace_impl';
36
37const SHOW_TRACK_DETAILS_BUTTON = featureFlags.register({
38  id: 'showTrackDetailsButton',
39  name: 'Show track details button',
40  description: 'Show track details button in track shells.',
41  defaultValue: false,
42});
43
44// Default height of a track element that has no track, or is collapsed.
45// Note: This is designed to roughly match the height of a cpu slice track.
46export const DEFAULT_TRACK_HEIGHT_PX = 30;
47
48interface TrackPanelAttrs {
49  readonly trace: TraceImpl;
50  readonly node: TrackNode;
51  readonly indentationLevel: number;
52  readonly trackRenderer?: TrackRenderer;
53  readonly revealOnCreate?: boolean;
54  readonly topOffsetPx: number;
55  readonly reorderable?: boolean;
56}
57
58export class TrackPanel implements Panel {
59  readonly kind = 'panel';
60  readonly selectable = true;
61  readonly trackNode?: TrackNode;
62
63  private readonly attrs: TrackPanelAttrs;
64
65  constructor(attrs: TrackPanelAttrs) {
66    this.attrs = attrs;
67    this.trackNode = attrs.node;
68  }
69
70  get heightPx(): number {
71    const {trackRenderer, node} = this.attrs;
72
73    // If the node is a summary track and is expanded, shrink it to save
74    // vertical real estate).
75    if (node.isSummary && node.expanded) return DEFAULT_TRACK_HEIGHT_PX;
76
77    // Otherwise return the height of the track, if we have one.
78    return trackRenderer?.track.getHeight() ?? DEFAULT_TRACK_HEIGHT_PX;
79  }
80
81  render(): m.Children {
82    const {
83      node,
84      indentationLevel,
85      trackRenderer,
86      revealOnCreate,
87      topOffsetPx,
88      reorderable = false,
89    } = this.attrs;
90
91    const error = trackRenderer?.getError();
92
93    const buttons = [
94      SHOW_TRACK_DETAILS_BUTTON.get() &&
95        renderTrackDetailsButton(node, trackRenderer?.desc),
96      trackRenderer?.track.getTrackShellButtons?.(),
97      node.removable && renderCloseButton(node),
98      // We don't want summary tracks to be pinned as they rarely have
99      // useful information.
100      !node.isSummary && renderPinButton(node),
101      this.renderAreaSelectionCheckbox(node),
102      error && renderCrashButton(error, trackRenderer?.desc.pluginId),
103    ];
104
105    let scrollIntoView = false;
106    const tracks = this.attrs.trace.tracks;
107    if (tracks.scrollToTrackNodeId === node.id) {
108      tracks.scrollToTrackNodeId = undefined;
109      scrollIntoView = true;
110    }
111
112    return m(TrackWidget, {
113      id: node.id,
114      title: node.title,
115      path: node.fullPath.join('/'),
116      heightPx: this.heightPx,
117      error: Boolean(trackRenderer?.getError()),
118      chips: trackRenderer?.desc.chips,
119      indentationLevel,
120      topOffsetPx,
121      buttons,
122      revealOnCreate: revealOnCreate || scrollIntoView,
123      collapsible: node.hasChildren,
124      collapsed: node.collapsed,
125      highlight: this.isHighlighted(node),
126      isSummary: node.isSummary,
127      reorderable,
128      onToggleCollapsed: () => {
129        node.hasChildren && node.toggleCollapsed();
130      },
131      onTrackContentMouseMove: (pos, bounds) => {
132        const timescale = this.getTimescaleForBounds(bounds);
133        trackRenderer?.track.onMouseMove?.({
134          ...pos,
135          timescale,
136        });
137        raf.scheduleCanvasRedraw();
138      },
139      onTrackContentMouseOut: () => {
140        trackRenderer?.track.onMouseOut?.();
141        raf.scheduleCanvasRedraw();
142      },
143      onTrackContentClick: (pos, bounds) => {
144        const timescale = this.getTimescaleForBounds(bounds);
145        raf.scheduleCanvasRedraw();
146        return (
147          trackRenderer?.track.onMouseClick?.({
148            ...pos,
149            timescale,
150          }) ?? false
151        );
152      },
153      onupdate: () => {
154        trackRenderer?.track.onFullRedraw?.();
155      },
156      onMoveBefore: (nodeId: string) => {
157        const targetNode = node.workspace?.getTrackById(nodeId);
158        if (targetNode !== undefined) {
159          // Insert the target node before this one
160          targetNode.parent?.addChildBefore(targetNode, node);
161        }
162      },
163      onMoveAfter: (nodeId: string) => {
164        const targetNode = node.workspace?.getTrackById(nodeId);
165        if (targetNode !== undefined) {
166          // Insert the target node after this one
167          targetNode.parent?.addChildAfter(targetNode, node);
168        }
169      },
170    });
171  }
172
173  renderCanvas(ctx: CanvasRenderingContext2D, size: Size2D) {
174    const {trackRenderer: tr, node} = this.attrs;
175
176    // Don't render if expanded and isSummary
177    if (node.isSummary && node.expanded) {
178      return;
179    }
180
181    const trackSize = {
182      width: size.width - TRACK_SHELL_WIDTH,
183      height: size.height,
184    };
185
186    using _ = canvasSave(ctx);
187    ctx.translate(TRACK_SHELL_WIDTH, 0);
188    canvasClip(ctx, 0, 0, trackSize.width, trackSize.height);
189
190    const visibleWindow = this.attrs.trace.timeline.visibleWindow;
191    const timescale = new TimeScale(visibleWindow, {
192      left: 0,
193      right: trackSize.width,
194    });
195
196    if (tr) {
197      if (!tr.getError()) {
198        const trackRenderCtx: TrackRenderContext = {
199          trackUri: tr.desc.uri,
200          visibleWindow,
201          size: trackSize,
202          resolution: calculateResolution(visibleWindow, trackSize.width),
203          ctx,
204          timescale,
205        };
206        tr.render(trackRenderCtx);
207      }
208    }
209
210    this.highlightIfTrackInAreaSelection(ctx, timescale, node, trackSize);
211  }
212
213  getSliceVerticalBounds(depth: number): VerticalBounds | undefined {
214    if (this.attrs.trackRenderer === undefined) {
215      return undefined;
216    }
217    return this.attrs.trackRenderer.track.getSliceVerticalBounds?.(depth);
218  }
219
220  private getTimescaleForBounds(bounds: Bounds2D) {
221    const timeWindow = this.attrs.trace.timeline.visibleWindow;
222    return new TimeScale(timeWindow, {
223      left: 0,
224      right: bounds.right - bounds.left,
225    });
226  }
227
228  private isHighlighted(node: TrackNode) {
229    // The track should be highlighted if the current search result matches this
230    // track or one of its children.
231    const searchIndex = this.attrs.trace.search.resultIndex;
232    const searchResults = this.attrs.trace.search.searchResults;
233
234    if (searchIndex !== -1 && searchResults !== undefined) {
235      const uri = searchResults.trackUris[searchIndex];
236      // Highlight if this or any children match the search results
237      if (
238        uri === node.uri ||
239        node.flatTracksOrdered.find((t) => t.uri === uri)
240      ) {
241        return true;
242      }
243    }
244
245    const curSelection = this.attrs.trace.selection;
246    if (
247      curSelection.selection.kind === 'track' &&
248      curSelection.selection.trackUri === node.uri
249    ) {
250      return true;
251    }
252
253    return false;
254  }
255
256  private highlightIfTrackInAreaSelection(
257    ctx: CanvasRenderingContext2D,
258    timescale: TimeScale,
259    node: TrackNode,
260    size: Size2D,
261  ) {
262    const selection = this.attrs.trace.selection.selection;
263    if (selection.kind !== 'area') {
264      return;
265    }
266
267    const tracksWithUris = node.flatTracks.filter(
268      (t) => t.uri !== undefined,
269    ) as ReadonlyArray<RequiredField<TrackNode, 'uri'>>;
270
271    let selected = false;
272    if (node.isSummary) {
273      selected = tracksWithUris.some((track) =>
274        selection.trackUris.includes(track.uri),
275      );
276    } else {
277      if (node.uri) {
278        selected = selection.trackUris.includes(node.uri);
279      }
280    }
281
282    if (selected) {
283      const selectedAreaDuration = selection.end - selection.start;
284      ctx.fillStyle = SELECTION_FILL_COLOR;
285      ctx.fillRect(
286        timescale.timeToPx(selection.start),
287        0,
288        timescale.durationToPx(selectedAreaDuration),
289        size.height,
290      );
291    }
292  }
293
294  private renderAreaSelectionCheckbox(node: TrackNode): m.Children {
295    const selectionManager = this.attrs.trace.selection;
296    const selection = selectionManager.selection;
297    if (selection.kind === 'area') {
298      if (node.isSummary) {
299        const tracksWithUris = node.flatTracks.filter(
300          (t) => t.uri !== undefined,
301        ) as ReadonlyArray<RequiredField<TrackNode, 'uri'>>;
302        // Check if any nodes within are selected
303        const childTracksInSelection = tracksWithUris.map((t) =>
304          selection.trackUris.includes(t.uri),
305        );
306        if (childTracksInSelection.every((b) => b)) {
307          return m(Button, {
308            onclick: (e: MouseEvent) => {
309              const uris = tracksWithUris.map((t) => t.uri);
310              selectionManager.toggleGroupAreaSelection(uris);
311              e.stopPropagation();
312            },
313            compact: true,
314            icon: Icons.Checkbox,
315            title: 'Remove child tracks from selection',
316          });
317        } else if (childTracksInSelection.some((b) => b)) {
318          return m(Button, {
319            onclick: (e: MouseEvent) => {
320              const uris = tracksWithUris.map((t) => t.uri);
321              selectionManager.toggleGroupAreaSelection(uris);
322              e.stopPropagation();
323            },
324            compact: true,
325            icon: Icons.IndeterminateCheckbox,
326            title: 'Add remaining child tracks to selection',
327          });
328        } else {
329          return m(Button, {
330            onclick: (e: MouseEvent) => {
331              const uris = tracksWithUris.map((t) => t.uri);
332              selectionManager.toggleGroupAreaSelection(uris);
333              e.stopPropagation();
334            },
335            compact: true,
336            icon: Icons.BlankCheckbox,
337            title: 'Add child tracks to selection',
338          });
339        }
340      } else {
341        const nodeUri = node.uri;
342        if (nodeUri) {
343          return (
344            selection.kind === 'area' &&
345            m(Button, {
346              onclick: (e: MouseEvent) => {
347                selectionManager.toggleTrackAreaSelection(nodeUri);
348                e.stopPropagation();
349              },
350              compact: true,
351              ...(selection.trackUris.includes(nodeUri)
352                ? {icon: Icons.Checkbox, title: 'Remove track'}
353                : {icon: Icons.BlankCheckbox, title: 'Add track to selection'}),
354            })
355          );
356        }
357      }
358    }
359    return undefined;
360  }
361}
362
363function renderCrashButton(error: Error, pluginId?: string) {
364  return m(
365    Popup,
366    {
367      trigger: m(Button, {
368        icon: Icons.Crashed,
369        compact: true,
370      }),
371    },
372    m(
373      '.pf-track-crash-popup',
374      m('span', 'This track has crashed.'),
375      pluginId && m('span', `Owning plugin: ${pluginId}`),
376      m(Button, {
377        label: 'View & Report Crash',
378        intent: Intent.Primary,
379        className: Popup.DISMISS_POPUP_GROUP_CLASS,
380        onclick: () => {
381          throw error;
382        },
383      }),
384      // TODO(stevegolton): In the future we should provide a quick way to
385      // disable the plugin, or provide a link to the plugin page, but this
386      // relies on the plugin page being fully functional.
387    ),
388  );
389}
390
391function renderCloseButton(node: TrackNode) {
392  return m(Button, {
393    onclick: (e) => {
394      node.remove();
395      e.stopPropagation();
396    },
397    icon: Icons.Close,
398    title: 'Close track',
399    compact: true,
400  });
401}
402
403function renderPinButton(node: TrackNode): m.Children {
404  const isPinned = node.isPinned;
405  return m(Button, {
406    className: classNames(!isPinned && 'pf-visible-on-hover'),
407    onclick: (e) => {
408      isPinned ? node.unpin() : node.pin();
409      e.stopPropagation();
410    },
411    icon: Icons.Pin,
412    iconFilled: isPinned,
413    title: isPinned ? 'Unpin' : 'Pin to top',
414    compact: true,
415  });
416}
417
418function renderTrackDetailsButton(
419  node: TrackNode,
420  td?: TrackDescriptor,
421): m.Children {
422  let parent = node.parent;
423  let fullPath: m.ChildArray = [node.title];
424  while (parent && parent instanceof TrackNode) {
425    fullPath = [parent.title, ' \u2023 ', ...fullPath];
426    parent = parent.parent;
427  }
428  return m(
429    Popup,
430    {
431      trigger: m(Button, {
432        className: 'pf-visible-on-hover',
433        icon: 'info',
434        title: 'Show track details',
435        compact: true,
436      }),
437      position: PopupPosition.Bottom,
438    },
439    m(
440      '.pf-track-details-dropdown',
441      m(
442        Tree,
443        m(TreeNode, {left: 'Track Node ID', right: node.id}),
444        m(TreeNode, {left: 'Collapsed', right: `${node.collapsed}`}),
445        m(TreeNode, {left: 'URI', right: node.uri}),
446        m(TreeNode, {left: 'Is Summary Track', right: `${node.isSummary}`}),
447        m(TreeNode, {
448          left: 'SortOrder',
449          right: node.sortOrder ?? '0 (undefined)',
450        }),
451        m(TreeNode, {left: 'Path', right: fullPath}),
452        m(TreeNode, {left: 'Title', right: node.title}),
453        m(TreeNode, {
454          left: 'Workspace',
455          right: node.workspace?.title ?? '[no workspace]',
456        }),
457        td && m(TreeNode, {left: 'Plugin ID', right: td.pluginId}),
458        td &&
459          m(
460            TreeNode,
461            {left: 'Tags'},
462            td.tags &&
463              Object.entries(td.tags).map(([key, value]) => {
464                return m(TreeNode, {left: key, right: value?.toString()});
465              }),
466          ),
467      ),
468    ),
469  );
470}
471