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