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