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