• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 {hex} from 'color-convert';
16import m from 'mithril';
17
18import {currentTargetOffset} from '../base/dom_utils';
19import {Icons} from '../base/semantic_icons';
20import {time} from '../base/time';
21import {Actions} from '../common/actions';
22import {TrackCacheEntry} from '../common/track_cache';
23import {raf} from '../core/raf_scheduler';
24import {SliceRect, Track, TrackTags} from '../public';
25
26import {checkerboard} from './checkerboard';
27import {SELECTION_FILL_COLOR, TRACK_SHELL_WIDTH} from './css_constants';
28import {globals} from './globals';
29import {drawGridLines} from './gridline_helper';
30import {PanelSize} from './panel';
31import {Panel} from './panel_container';
32import {verticalScrollToTrack} from './scroll_helper';
33import {drawVerticalLineAtTime} from './vertical_line_helper';
34import {classNames} from '../base/classnames';
35import {Button, ButtonBar} from '../widgets/button';
36import {Popup} from '../widgets/popup';
37import {canvasClip} from '../common/canvas_utils';
38import {TimeScale} from './time_scale';
39import {getLegacySelection} from '../common/state';
40import {CloseTrackButton} from './close_track_button';
41import {exists} from '../base/utils';
42import {Intent} from '../widgets/common';
43
44function getTitleSize(title: string): string | undefined {
45  const length = title.length;
46  if (length > 55) {
47    return '9px';
48  }
49  if (length > 50) {
50    return '10px';
51  }
52  if (length > 45) {
53    return '11px';
54  }
55  if (length > 40) {
56    return '12px';
57  }
58  if (length > 35) {
59    return '13px';
60  }
61  return undefined;
62}
63
64function isTrackPinned(trackKey: string) {
65  return globals.state.pinnedTracks.indexOf(trackKey) !== -1;
66}
67
68function isTrackSelected(trackKey: string) {
69  const selection = globals.state.selection;
70  if (selection.kind !== 'area') return false;
71  return selection.tracks.includes(trackKey);
72}
73
74interface TrackChipAttrs {
75  text: string;
76}
77
78class TrackChip implements m.ClassComponent<TrackChipAttrs> {
79  view({attrs}: m.CVnode<TrackChipAttrs>) {
80    return m('span.chip', attrs.text);
81  }
82}
83
84export function renderChips(tags?: TrackTags) {
85  return [
86    tags?.metric && m(TrackChip, {text: 'metric'}),
87    tags?.debuggable && m(TrackChip, {text: 'debuggable'}),
88  ];
89}
90
91export interface CrashButtonAttrs {
92  error: Error;
93}
94
95export class CrashButton implements m.ClassComponent<CrashButtonAttrs> {
96  view({attrs}: m.Vnode<CrashButtonAttrs>): m.Children {
97    return m(
98      Popup,
99      {
100        trigger: m(Button, {
101          icon: Icons.Crashed,
102          compact: true,
103        }),
104      },
105      this.renderErrorMessage(attrs.error),
106    );
107  }
108
109  private renderErrorMessage(error: Error): m.Children {
110    return m(
111      '',
112      'This track has crashed',
113      m(Button, {
114        label: 'Re-raise exception',
115        intent: Intent.Primary,
116        className: Popup.DISMISS_POPUP_GROUP_CLASS,
117        onclick: () => {
118          throw error;
119        },
120      }),
121    );
122  }
123}
124
125interface TrackShellAttrs {
126  trackKey: string;
127  title: string;
128  buttons: m.Children;
129  tags?: TrackTags;
130  button?: string;
131}
132
133class TrackShell implements m.ClassComponent<TrackShellAttrs> {
134  // Set to true when we click down and drag the
135  private dragging = false;
136  private dropping: 'before' | 'after' | undefined = undefined;
137
138  view({attrs}: m.CVnode<TrackShellAttrs>) {
139    // The shell should be highlighted if the current search result is inside
140    // this track.
141    let highlightClass = undefined;
142    const searchIndex = globals.state.searchIndex;
143    if (searchIndex !== -1) {
144      const trackKey = globals.currentSearchResults.trackKeys[searchIndex];
145      if (trackKey === attrs.trackKey) {
146        highlightClass = 'flash';
147      }
148    }
149
150    const currentSelection = globals.state.selection;
151    const pinned = isTrackPinned(attrs.trackKey);
152
153    return m(
154      `.track-shell[draggable=true]`,
155      {
156        className: classNames(
157          highlightClass,
158          this.dragging && 'drag',
159          this.dropping && `drop-${this.dropping}`,
160        ),
161        ondragstart: (e: DragEvent) => this.ondragstart(e, attrs.trackKey),
162        ondragend: this.ondragend.bind(this),
163        ondragover: this.ondragover.bind(this),
164        ondragleave: this.ondragleave.bind(this),
165        ondrop: (e: DragEvent) => this.ondrop(e, attrs.trackKey),
166      },
167      m(
168        '.track-menubar',
169        m(
170          'h1',
171          {
172            title: attrs.title,
173            style: {
174              'font-size': getTitleSize(attrs.title),
175            },
176          },
177          attrs.title,
178          renderChips(attrs.tags),
179        ),
180        m(
181          ButtonBar,
182          {className: 'track-buttons'},
183          attrs.buttons,
184          m(Button, {
185            className: classNames(!pinned && 'pf-visible-on-hover'),
186            onclick: () => {
187              globals.dispatch(
188                Actions.toggleTrackPinned({trackKey: attrs.trackKey}),
189              );
190            },
191            icon: Icons.Pin,
192            iconFilled: pinned,
193            title: pinned ? 'Unpin' : 'Pin to top',
194            compact: true,
195          }),
196          currentSelection.kind === 'area'
197            ? m(Button, {
198                onclick: (e: MouseEvent) => {
199                  globals.dispatch(
200                    Actions.toggleTrackSelection({
201                      key: attrs.trackKey,
202                      isTrackGroup: false,
203                    }),
204                  );
205                  e.stopPropagation();
206                },
207                compact: true,
208                icon: isTrackSelected(attrs.trackKey)
209                  ? Icons.Checkbox
210                  : Icons.BlankCheckbox,
211                title: isTrackSelected(attrs.trackKey)
212                  ? 'Remove track'
213                  : 'Add track to selection',
214              })
215            : '',
216        ),
217      ),
218    );
219  }
220
221  ondragstart(e: DragEvent, trackKey: string) {
222    const dataTransfer = e.dataTransfer;
223    if (dataTransfer === null) return;
224    this.dragging = true;
225    raf.scheduleFullRedraw();
226    dataTransfer.setData('perfetto/track', `${trackKey}`);
227    dataTransfer.setDragImage(new Image(), 0, 0);
228  }
229
230  ondragend() {
231    this.dragging = false;
232    raf.scheduleFullRedraw();
233  }
234
235  ondragover(e: DragEvent) {
236    if (this.dragging) return;
237    if (!(e.target instanceof HTMLElement)) return;
238    const dataTransfer = e.dataTransfer;
239    if (dataTransfer === null) return;
240    if (!dataTransfer.types.includes('perfetto/track')) return;
241    dataTransfer.dropEffect = 'move';
242    e.preventDefault();
243
244    // Apply some hysteresis to the drop logic so that the lightened border
245    // changes only when we get close enough to the border.
246    if (e.offsetY < e.target.scrollHeight / 3) {
247      this.dropping = 'before';
248    } else if (e.offsetY > (e.target.scrollHeight / 3) * 2) {
249      this.dropping = 'after';
250    }
251    raf.scheduleFullRedraw();
252  }
253
254  ondragleave() {
255    this.dropping = undefined;
256    raf.scheduleFullRedraw();
257  }
258
259  ondrop(e: DragEvent, trackKey: string) {
260    if (this.dropping === undefined) return;
261    const dataTransfer = e.dataTransfer;
262    if (dataTransfer === null) return;
263    raf.scheduleFullRedraw();
264    const srcId = dataTransfer.getData('perfetto/track');
265    const dstId = trackKey;
266    globals.dispatch(Actions.moveTrack({srcId, op: this.dropping, dstId}));
267    this.dropping = undefined;
268  }
269}
270
271export interface TrackContentAttrs {
272  track: Track;
273  hasError?: boolean;
274  height?: number;
275}
276export class TrackContent implements m.ClassComponent<TrackContentAttrs> {
277  private mouseDownX?: number;
278  private mouseDownY?: number;
279  private selectionOccurred = false;
280
281  view(node: m.CVnode<TrackContentAttrs>) {
282    const attrs = node.attrs;
283    return m(
284      '.track-content',
285      {
286        style: exists(attrs.height) && {
287          height: `${attrs.height}px`,
288        },
289        className: classNames(attrs.hasError && 'pf-track-content-error'),
290        onmousemove: (e: MouseEvent) => {
291          attrs.track.onMouseMove?.(currentTargetOffset(e));
292          raf.scheduleRedraw();
293        },
294        onmouseout: () => {
295          attrs.track.onMouseOut?.();
296          raf.scheduleRedraw();
297        },
298        onmousedown: (e: MouseEvent) => {
299          const {x, y} = currentTargetOffset(e);
300          this.mouseDownX = x;
301          this.mouseDownY = y;
302        },
303        onmouseup: (e: MouseEvent) => {
304          if (this.mouseDownX === undefined || this.mouseDownY === undefined) {
305            return;
306          }
307          const {x, y} = currentTargetOffset(e);
308          if (
309            Math.abs(x - this.mouseDownX) > 1 ||
310            Math.abs(y - this.mouseDownY) > 1
311          ) {
312            this.selectionOccurred = true;
313          }
314          this.mouseDownX = undefined;
315          this.mouseDownY = undefined;
316        },
317        onclick: (e: MouseEvent) => {
318          // This click event occurs after any selection mouse up/drag events
319          // so we have to look if the mouse moved during this click to know
320          // if a selection occurred.
321          if (this.selectionOccurred) {
322            this.selectionOccurred = false;
323            return;
324          }
325          // Returns true if something was selected, so stop propagation.
326          if (attrs.track.onMouseClick?.(currentTargetOffset(e))) {
327            e.stopPropagation();
328          }
329          raf.scheduleRedraw();
330        },
331      },
332      node.children,
333    );
334  }
335}
336
337interface TrackComponentAttrs {
338  trackKey: string;
339  heightPx?: number;
340  title: string;
341  buttons?: m.Children;
342  tags?: TrackTags;
343  track?: Track;
344  error?: Error | undefined;
345  closeable: boolean;
346
347  // Issues a scrollTo() on this DOM element at creation time. Default: false.
348  revealOnCreate?: boolean;
349}
350
351class TrackComponent implements m.ClassComponent<TrackComponentAttrs> {
352  view({attrs}: m.CVnode<TrackComponentAttrs>) {
353    // TODO(hjd): The min height below must match the track_shell_title
354    // max height in common.scss so we should read it from CSS to avoid
355    // them going out of sync.
356    const TRACK_HEIGHT_MIN_PX = 18;
357    const TRACK_HEIGHT_DEFAULT_PX = 24;
358    const trackHeightRaw = attrs.heightPx ?? TRACK_HEIGHT_DEFAULT_PX;
359    const trackHeight = Math.max(trackHeightRaw, TRACK_HEIGHT_MIN_PX);
360
361    return m(
362      '.track',
363      {
364        style: {
365          // Note: Sub-pixel track heights can mess with sticky elements.
366          // Round up to the nearest integer number of pixels.
367          height: `${Math.ceil(trackHeight)}px`,
368        },
369        id: 'track_' + attrs.trackKey,
370      },
371      [
372        m(TrackShell, {
373          buttons: [
374            attrs.error && m(CrashButton, {error: attrs.error}),
375            attrs.closeable && m(CloseTrackButton, {trackKey: attrs.trackKey}),
376            attrs.buttons,
377          ],
378          title: attrs.title,
379          trackKey: attrs.trackKey,
380          tags: attrs.tags,
381        }),
382        attrs.track &&
383          m(TrackContent, {
384            track: attrs.track,
385            hasError: Boolean(attrs.error),
386            height: attrs.heightPx,
387          }),
388      ],
389    );
390  }
391
392  oncreate(vnode: m.VnodeDOM<TrackComponentAttrs>) {
393    const {attrs} = vnode;
394    if (globals.scrollToTrackKey === attrs.trackKey) {
395      verticalScrollToTrack(attrs.trackKey);
396      globals.scrollToTrackKey = undefined;
397    }
398    this.onupdate(vnode);
399
400    if (attrs.revealOnCreate) {
401      vnode.dom.scrollIntoView();
402    }
403  }
404
405  onupdate(vnode: m.VnodeDOM<TrackComponentAttrs>) {
406    vnode.attrs.track?.onFullRedraw?.();
407  }
408}
409
410interface TrackPanelAttrs {
411  trackKey: string;
412  title: string;
413  tags?: TrackTags;
414  trackFSM?: TrackCacheEntry;
415  revealOnCreate?: boolean;
416  closeable: boolean;
417}
418
419export class TrackPanel implements Panel {
420  readonly kind = 'panel';
421  readonly selectable = true;
422
423  constructor(private readonly attrs: TrackPanelAttrs) {}
424
425  get trackKey(): string {
426    return this.attrs.trackKey;
427  }
428
429  render(): m.Children {
430    const attrs = this.attrs;
431
432    if (attrs.trackFSM) {
433      if (attrs.trackFSM.getError()) {
434        return m(TrackComponent, {
435          title: attrs.title,
436          trackKey: attrs.trackKey,
437          error: attrs.trackFSM.getError(),
438          track: attrs.trackFSM.track,
439          closeable: attrs.closeable,
440        });
441      }
442      return m(TrackComponent, {
443        trackKey: attrs.trackKey,
444        title: attrs.title,
445        heightPx: attrs.trackFSM.track.getHeight(),
446        buttons: attrs.trackFSM.track.getTrackShellButtons?.(),
447        tags: attrs.tags,
448        track: attrs.trackFSM.track,
449        error: attrs.trackFSM.getError(),
450        revealOnCreate: attrs.revealOnCreate,
451        closeable: attrs.closeable,
452      });
453    } else {
454      return m(TrackComponent, {
455        trackKey: attrs.trackKey,
456        title: attrs.title,
457        revealOnCreate: attrs.revealOnCreate,
458        closeable: attrs.closeable,
459      });
460    }
461  }
462
463  highlightIfTrackSelected(ctx: CanvasRenderingContext2D, size: PanelSize) {
464    const {visibleTimeScale} = globals.timeline;
465    const selection = globals.state.selection;
466    if (selection.kind !== 'area') {
467      return;
468    }
469    const selectedAreaDuration = selection.end - selection.start;
470    if (selection.tracks.includes(this.attrs.trackKey)) {
471      ctx.fillStyle = SELECTION_FILL_COLOR;
472      ctx.fillRect(
473        visibleTimeScale.timeToPx(selection.start) + TRACK_SHELL_WIDTH,
474        0,
475        visibleTimeScale.durationToPx(selectedAreaDuration),
476        size.height,
477      );
478    }
479  }
480
481  renderCanvas(ctx: CanvasRenderingContext2D, size: PanelSize) {
482    ctx.save();
483    canvasClip(
484      ctx,
485      TRACK_SHELL_WIDTH,
486      0,
487      size.width - TRACK_SHELL_WIDTH,
488      size.height,
489    );
490
491    drawGridLines(ctx, size.width, size.height);
492
493    const track = this.attrs.trackFSM;
494
495    ctx.save();
496    ctx.translate(TRACK_SHELL_WIDTH, 0);
497    if (track !== undefined) {
498      const trackSize = {...size, width: size.width - TRACK_SHELL_WIDTH};
499      if (!track.getError()) {
500        track.update();
501        track.track.render(ctx, trackSize);
502      }
503    } else {
504      checkerboard(ctx, size.height, 0, size.width - TRACK_SHELL_WIDTH);
505    }
506    ctx.restore();
507
508    this.highlightIfTrackSelected(ctx, size);
509
510    const {visibleTimeScale} = globals.timeline;
511    // Draw vertical line when hovering on the notes panel.
512    renderHoveredNoteVertical(ctx, visibleTimeScale, size);
513    renderHoveredCursorVertical(ctx, visibleTimeScale, size);
514    renderWakeupVertical(ctx, visibleTimeScale, size);
515    renderNoteVerticals(ctx, visibleTimeScale, size);
516
517    ctx.restore();
518  }
519
520  getSliceRect(tStart: time, tDur: time, depth: number): SliceRect | undefined {
521    if (this.attrs.trackFSM === undefined) {
522      return undefined;
523    }
524    return this.attrs.trackFSM.track.getSliceRect?.(tStart, tDur, depth);
525  }
526}
527
528export function renderHoveredCursorVertical(
529  ctx: CanvasRenderingContext2D,
530  visibleTimeScale: TimeScale,
531  size: PanelSize,
532) {
533  if (globals.state.hoverCursorTimestamp !== -1n) {
534    drawVerticalLineAtTime(
535      ctx,
536      visibleTimeScale,
537      globals.state.hoverCursorTimestamp,
538      size.height,
539      `#344596`,
540    );
541  }
542}
543
544export function renderHoveredNoteVertical(
545  ctx: CanvasRenderingContext2D,
546  visibleTimeScale: TimeScale,
547  size: PanelSize,
548) {
549  if (globals.state.hoveredNoteTimestamp !== -1n) {
550    drawVerticalLineAtTime(
551      ctx,
552      visibleTimeScale,
553      globals.state.hoveredNoteTimestamp,
554      size.height,
555      `#aaa`,
556    );
557  }
558}
559
560export function renderWakeupVertical(
561  ctx: CanvasRenderingContext2D,
562  visibleTimeScale: TimeScale,
563  size: PanelSize,
564) {
565  const currentSelection = getLegacySelection(globals.state);
566  if (currentSelection !== null) {
567    if (
568      currentSelection.kind === 'SCHED_SLICE' &&
569      globals.sliceDetails.wakeupTs !== undefined
570    ) {
571      drawVerticalLineAtTime(
572        ctx,
573        visibleTimeScale,
574        globals.sliceDetails.wakeupTs,
575        size.height,
576        `black`,
577      );
578    }
579  }
580}
581
582export function renderNoteVerticals(
583  ctx: CanvasRenderingContext2D,
584  visibleTimeScale: TimeScale,
585  size: PanelSize,
586) {
587  // All marked areas should have semi-transparent vertical lines
588  // marking the start and end.
589  for (const note of Object.values(globals.state.notes)) {
590    if (note.noteType === 'SPAN') {
591      const transparentNoteColor =
592        'rgba(' + hex.rgb(note.color.substr(1)).toString() + ', 0.65)';
593      drawVerticalLineAtTime(
594        ctx,
595        visibleTimeScale,
596        note.start,
597        size.height,
598        transparentNoteColor,
599        1,
600      );
601      drawVerticalLineAtTime(
602        ctx,
603        visibleTimeScale,
604        note.end,
605        size.height,
606        transparentNoteColor,
607        1,
608      );
609    } else if (note.noteType === 'DEFAULT') {
610      drawVerticalLineAtTime(
611        ctx,
612        visibleTimeScale,
613        note.timestamp,
614        size.height,
615        note.color,
616      );
617    }
618  }
619}
620