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 * as m from 'mithril';
17
18import {Actions} from '../common/actions';
19import {TrackState} from '../common/state';
20
21import {TRACK_SHELL_WIDTH} from './css_constants';
22import {PerfettoMouseEvent} from './events';
23import {globals} from './globals';
24import {drawGridLines} from './gridline_helper';
25import {BLANK_CHECKBOX, CHECKBOX, STAR, STAR_BORDER} from './icons';
26import {Panel, PanelSize} from './panel';
27import {verticalScrollToTrack} from './scroll_helper';
28import {SliceRect, Track} from './track';
29import {trackRegistry} from './track_registry';
30import {
31  drawVerticalLineAtTime,
32} from './vertical_line_helper';
33
34function isPinned(id: string) {
35  return globals.state.pinnedTracks.indexOf(id) !== -1;
36}
37
38function isSelected(id: string) {
39  const selection = globals.state.currentSelection;
40  if (selection === null || selection.kind !== 'AREA') return false;
41  const selectedArea = globals.state.areas[selection.areaId];
42  return selectedArea.tracks.includes(id);
43}
44
45interface TrackShellAttrs {
46  track: Track;
47  trackState: TrackState;
48}
49
50class TrackShell implements m.ClassComponent<TrackShellAttrs> {
51  // Set to true when we click down and drag the
52  private dragging = false;
53  private dropping: 'before'|'after'|undefined = undefined;
54  private attrs?: TrackShellAttrs;
55
56  oninit(vnode: m.Vnode<TrackShellAttrs>) {
57    this.attrs = vnode.attrs;
58  }
59
60  view({attrs}: m.CVnode<TrackShellAttrs>) {
61    // The shell should be highlighted if the current search result is inside
62    // this track.
63    let highlightClass = '';
64    const searchIndex = globals.frontendLocalState.searchIndex;
65    if (searchIndex !== -1) {
66      const trackId = globals.currentSearchResults
67                          .trackIds[globals.frontendLocalState.searchIndex];
68      if (trackId === attrs.trackState.id) {
69        highlightClass = 'flash';
70      }
71    }
72
73    const dragClass = this.dragging ? `drag` : '';
74    const dropClass = this.dropping ? `drop-${this.dropping}` : '';
75    return m(
76        `.track-shell[draggable=true]`,
77        {
78          class: `${highlightClass} ${dragClass} ${dropClass}`,
79          onmousedown: this.onmousedown.bind(this),
80          ondragstart: this.ondragstart.bind(this),
81          ondragend: this.ondragend.bind(this),
82          ondragover: this.ondragover.bind(this),
83          ondragleave: this.ondragleave.bind(this),
84          ondrop: this.ondrop.bind(this),
85        },
86        m('h1',
87          {
88            title: attrs.trackState.name,
89          },
90          attrs.trackState.name),
91        m('.track-buttons',
92          attrs.track.getTrackShellButtons(),
93          m(TrackButton, {
94            action: () => {
95              globals.dispatch(
96                  Actions.toggleTrackPinned({trackId: attrs.trackState.id}));
97            },
98            i: isPinned(attrs.trackState.id) ? STAR : STAR_BORDER,
99            tooltip: isPinned(attrs.trackState.id) ? 'Unpin' : 'Pin to top',
100            showButton: isPinned(attrs.trackState.id),
101          }),
102          globals.state.currentSelection !== null &&
103                  globals.state.currentSelection.kind === 'AREA' ?
104              m(TrackButton, {
105                action: (e: PerfettoMouseEvent) => {
106                  globals.dispatch(Actions.toggleTrackSelection(
107                      {id: attrs.trackState.id, isTrackGroup: false}));
108                  e.stopPropagation();
109                },
110                i: isSelected(attrs.trackState.id) ? CHECKBOX : BLANK_CHECKBOX,
111                tooltip: isSelected(attrs.trackState.id) ?
112                    'Remove track' :
113                    'Add track to selection',
114                showButton: true,
115              }) :
116              ''));
117  }
118
119  onmousedown(e: MouseEvent) {
120    // Prevent that the click is intercepted by the PanAndZoomHandler and that
121    // we start panning while dragging.
122    e.stopPropagation();
123  }
124
125  ondragstart(e: DragEvent) {
126    const dataTransfer = e.dataTransfer;
127    if (dataTransfer === null) return;
128    this.dragging = true;
129    globals.rafScheduler.scheduleFullRedraw();
130    dataTransfer.setData('perfetto/track', `${this.attrs!.trackState.id}`);
131    dataTransfer.setDragImage(new Image(), 0, 0);
132    e.stopImmediatePropagation();
133  }
134
135  ondragend() {
136    this.dragging = false;
137    globals.rafScheduler.scheduleFullRedraw();
138  }
139
140  ondragover(e: DragEvent) {
141    if (this.dragging) return;
142    if (!(e.target instanceof HTMLElement)) return;
143    const dataTransfer = e.dataTransfer;
144    if (dataTransfer === null) return;
145    if (!dataTransfer.types.includes('perfetto/track')) return;
146    dataTransfer.dropEffect = 'move';
147    e.preventDefault();
148
149    // Apply some hysteresis to the drop logic so that the lightened border
150    // changes only when we get close enough to the border.
151    if (e.offsetY < e.target.scrollHeight / 3) {
152      this.dropping = 'before';
153    } else if (e.offsetY > e.target.scrollHeight / 3 * 2) {
154      this.dropping = 'after';
155    }
156    globals.rafScheduler.scheduleFullRedraw();
157  }
158
159  ondragleave() {
160    this.dropping = undefined;
161    globals.rafScheduler.scheduleFullRedraw();
162  }
163
164  ondrop(e: DragEvent) {
165    if (this.dropping === undefined) return;
166    const dataTransfer = e.dataTransfer;
167    if (dataTransfer === null) return;
168    globals.rafScheduler.scheduleFullRedraw();
169    const srcId = dataTransfer.getData('perfetto/track');
170    const dstId = this.attrs!.trackState.id;
171    globals.dispatch(Actions.moveTrack({srcId, op: this.dropping, dstId}));
172    this.dropping = undefined;
173  }
174}
175
176export interface TrackContentAttrs { track: Track; }
177export class TrackContent implements m.ClassComponent<TrackContentAttrs> {
178  private mouseDownX?: number;
179  private mouseDownY?: number;
180  private selectionOccurred = false;
181
182  view({attrs}: m.CVnode<TrackContentAttrs>) {
183    return m('.track-content', {
184      onmousemove: (e: PerfettoMouseEvent) => {
185        attrs.track.onMouseMove({x: e.layerX - TRACK_SHELL_WIDTH, y: e.layerY});
186        globals.rafScheduler.scheduleRedraw();
187      },
188      onmouseout: () => {
189        attrs.track.onMouseOut();
190        globals.rafScheduler.scheduleRedraw();
191      },
192      onmousedown: (e: PerfettoMouseEvent) => {
193        this.mouseDownX = e.layerX;
194        this.mouseDownY = e.layerY;
195      },
196      onmouseup: (e: PerfettoMouseEvent) => {
197        if (this.mouseDownX === undefined || this.mouseDownY === undefined) {
198          return;
199        }
200        if (Math.abs(e.layerX - this.mouseDownX) > 1 ||
201            Math.abs(e.layerY - this.mouseDownY) > 1) {
202          this.selectionOccurred = true;
203        }
204        this.mouseDownX = undefined;
205        this.mouseDownY = undefined;
206      },
207      onclick: (e: PerfettoMouseEvent) => {
208        // This click event occurs after any selection mouse up/drag events
209        // so we have to look if the mouse moved during this click to know
210        // if a selection occurred.
211        if (this.selectionOccurred) {
212          this.selectionOccurred = false;
213          return;
214        }
215        // Returns true if something was selected, so stop propagation.
216        if (attrs.track.onMouseClick(
217                {x: e.layerX - TRACK_SHELL_WIDTH, y: e.layerY})) {
218          e.stopPropagation();
219        }
220        globals.rafScheduler.scheduleRedraw();
221      }
222    });
223  }
224}
225
226interface TrackComponentAttrs {
227  trackState: TrackState;
228  track: Track;
229}
230class TrackComponent implements m.ClassComponent<TrackComponentAttrs> {
231  view({attrs}: m.CVnode<TrackComponentAttrs>) {
232    return m(
233        '.track',
234        {
235          style: {
236            height: `${Math.max(24, attrs.track.getHeight())}px`,
237          },
238          id: 'track_' + attrs.trackState.id,
239        },
240        [
241          m(TrackShell, {track: attrs.track, trackState: attrs.trackState}),
242          m(TrackContent, {track: attrs.track})
243        ]);
244  }
245
246  oncreate({attrs}: m.CVnode<TrackComponentAttrs>) {
247    if (globals.frontendLocalState.scrollToTrackId === attrs.trackState.id) {
248      verticalScrollToTrack(attrs.trackState.id);
249      globals.frontendLocalState.scrollToTrackId = undefined;
250    }
251  }
252}
253
254export interface TrackButtonAttrs {
255  action: (e: PerfettoMouseEvent) => void;
256  i: string;
257  tooltip: string;
258  showButton: boolean;
259}
260export class TrackButton implements m.ClassComponent<TrackButtonAttrs> {
261  view({attrs}: m.CVnode<TrackButtonAttrs>) {
262    return m(
263        'i.material-icons.track-button',
264        {
265          class: `${attrs.showButton ? 'show' : ''}`,
266          onclick: attrs.action,
267          title: attrs.tooltip,
268        },
269        attrs.i);
270  }
271}
272
273interface TrackPanelAttrs {
274  id: string;
275  selectable: boolean;
276}
277
278export class TrackPanel extends Panel<TrackPanelAttrs> {
279  private track: Track;
280  private trackState: TrackState;
281  constructor(vnode: m.CVnode<TrackPanelAttrs>) {
282    super();
283    this.trackState = globals.state.tracks[vnode.attrs.id];
284    const trackCreator = trackRegistry.get(this.trackState.kind);
285    this.track = trackCreator.create(this.trackState);
286  }
287
288  view() {
289    return m(TrackComponent, {trackState: this.trackState, track: this.track});
290  }
291
292  highlightIfTrackSelected(ctx: CanvasRenderingContext2D, size: PanelSize) {
293    const localState = globals.frontendLocalState;
294    const selection = globals.state.currentSelection;
295    if (!selection || selection.kind !== 'AREA') return;
296    const selectedArea = globals.state.areas[selection.areaId];
297    if (selectedArea.tracks.includes(this.trackState.id)) {
298      const timeScale = localState.timeScale;
299      ctx.fillStyle = 'rgba(131, 152, 230, 0.3)';
300      ctx.fillRect(
301          timeScale.timeToPx(selectedArea.startSec) + TRACK_SHELL_WIDTH,
302          0,
303          timeScale.deltaTimeToPx(selectedArea.endSec - selectedArea.startSec),
304          size.height);
305    }
306  }
307
308  renderCanvas(ctx: CanvasRenderingContext2D, size: PanelSize) {
309    ctx.save();
310
311    drawGridLines(
312        ctx,
313        globals.frontendLocalState.timeScale,
314        globals.frontendLocalState.visibleWindowTime,
315        size.width,
316        size.height);
317
318    ctx.translate(TRACK_SHELL_WIDTH, 0);
319    this.track.render(ctx);
320    ctx.restore();
321
322    this.highlightIfTrackSelected(ctx, size);
323
324    const localState = globals.frontendLocalState;
325    // Draw vertical line when hovering on the notes panel.
326    if (localState.hoveredNoteTimestamp !== -1) {
327      drawVerticalLineAtTime(
328          ctx,
329          localState.timeScale,
330          localState.hoveredNoteTimestamp,
331          size.height,
332          `#aaa`);
333    }
334    if (localState.hoveredLogsTimestamp !== -1) {
335      drawVerticalLineAtTime(
336          ctx,
337          localState.timeScale,
338          localState.hoveredLogsTimestamp,
339          size.height,
340          `#344596`);
341    }
342    if (globals.state.currentSelection !== null) {
343      if (globals.state.currentSelection.kind === 'NOTE') {
344        const note = globals.state.notes[globals.state.currentSelection.id];
345        if (note.noteType === 'DEFAULT') {
346          drawVerticalLineAtTime(
347              ctx,
348              localState.timeScale,
349              note.timestamp,
350              size.height,
351              note.color);
352        }
353      }
354
355      if (globals.state.currentSelection.kind === 'SLICE' &&
356          globals.sliceDetails.wakeupTs !== undefined) {
357        drawVerticalLineAtTime(
358            ctx,
359            localState.timeScale,
360            globals.sliceDetails.wakeupTs,
361            size.height,
362            `black`);
363      }
364    }
365    // All marked areas should have semi-transparent vertical lines
366    // marking the start and end.
367    for (const note of Object.values(globals.state.notes)) {
368      if (note.noteType === 'AREA') {
369        const transparentNoteColor =
370            'rgba(' + hex.rgb(note.color.substr(1)).toString() + ', 0.65)';
371        drawVerticalLineAtTime(
372            ctx,
373            localState.timeScale,
374            globals.state.areas[note.areaId].startSec,
375            size.height,
376            transparentNoteColor,
377            1);
378        drawVerticalLineAtTime(
379            ctx,
380            localState.timeScale,
381            globals.state.areas[note.areaId].endSec,
382            size.height,
383            transparentNoteColor,
384            1);
385      }
386    }
387  }
388
389  getSliceRect(tStart: number, tDur: number, depth: number): SliceRect
390      |undefined {
391    return this.track.getSliceRect(tStart, tDur, depth);
392  }
393}
394