1// Copyright (C) 2024 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 15/** 16 * This module provides the TrackNodeTree mithril component, which is 17 * responsible for rendering out a tree of tracks and drawing their content 18 * onto the canvas. 19 * - Rendering track panels and handling nested and sticky headers. 20 * - Managing the virtual canvas & drawing the grid-lines, tracks and overlays 21 * onto the canvas. 22 * - Handling track interaction events such as dragging, panning and scrolling. 23 */ 24 25import m from 'mithril'; 26import {canvasClip, canvasSave} from '../../base/canvas_utils'; 27import {classNames} from '../../base/classnames'; 28import {Bounds2D, Rect2D, Size2D, VerticalBounds} from '../../base/geom'; 29import {HighPrecisionTimeSpan} from '../../base/high_precision_time_span'; 30import {Icons} from '../../base/semantic_icons'; 31import {TimeScale} from '../../base/time_scale'; 32import {RequiredField} from '../../base/utils'; 33import {PerfStats, runningStatStr} from '../../core/perf_stats'; 34import {raf} from '../../core/raf_scheduler'; 35import {TraceImpl} from '../../core/trace_impl'; 36import {TrackWithFSM} from '../../core/track_manager'; 37import {TrackRenderer, Track} from '../../public/track'; 38import {TrackNode, Workspace} from '../../public/workspace'; 39import {Button} from '../../widgets/button'; 40import {MenuDivider, MenuItem, PopupMenu} from '../../widgets/menu'; 41import {TrackShell} from '../../widgets/track_shell'; 42import {Tree, TreeNode} from '../../widgets/tree'; 43import {SELECTION_FILL_COLOR} from '../css_constants'; 44import {calculateResolution} from './resolution'; 45import {Trace} from '../../public/trace'; 46import {Anchor} from '../../widgets/anchor'; 47import {showModal} from '../../widgets/modal'; 48import {copyToClipboard} from '../../base/clipboard'; 49 50const TRACK_HEIGHT_MIN_PX = 18; 51const TRACK_HEIGHT_DEFAULT_PX = 30; 52 53function getTrackHeight(node: TrackNode, track?: TrackRenderer) { 54 // Headless tracks have an effective height of 0. 55 if (node.headless) return 0; 56 57 // Expanded summary tracks don't show any data, so make them a little more 58 // compact to save space. 59 if (node.isSummary && node.expanded) return TRACK_HEIGHT_DEFAULT_PX; 60 61 const trackHeight = track?.getHeight(); 62 if (trackHeight === undefined) return TRACK_HEIGHT_DEFAULT_PX; 63 64 // Limit the minimum height of a track, and also round up to the nearest 65 // integer, as sub-integer DOM alignment can cause issues e.g. with sticky 66 // positioning. 67 return Math.ceil(Math.max(trackHeight, TRACK_HEIGHT_MIN_PX)); 68} 69 70export interface TrackViewAttrs { 71 // Render a lighter version of this track view (for when tracks are offscreen). 72 readonly lite: boolean; 73 readonly scrollToOnCreate?: boolean; 74 readonly reorderable?: boolean; 75 readonly removable?: boolean; 76 readonly depth: number; 77 readonly stickyTop: number; 78 readonly collapsible: boolean; 79} 80 81/** 82 * The `TrackView` class is responsible for managing and rendering individual 83 * tracks in the `TrackTreeView` Mithril component. It handles operations such 84 * as: 85 * 86 * - Rendering track content in the DOM and virtual canvas. 87 * - Managing user interactions like dragging, panning, scrolling, and area 88 * selection. 89 * - Tracking and displaying rendering performance metrics. 90 */ 91export class TrackView { 92 readonly node: TrackNode; 93 readonly renderer?: TrackWithFSM; 94 readonly height: number; 95 readonly verticalBounds: VerticalBounds; 96 97 private readonly trace: TraceImpl; 98 private readonly descriptor?: Track; 99 100 constructor(trace: TraceImpl, node: TrackNode, top: number) { 101 this.trace = trace; 102 this.node = node; 103 104 if (node.uri) { 105 this.descriptor = trace.tracks.getTrack(node.uri); 106 this.renderer = this.trace.tracks.getTrackFSM(node.uri); 107 } 108 109 const heightPx = getTrackHeight(node, this.renderer?.track); 110 this.height = heightPx; 111 this.verticalBounds = {top, bottom: top + heightPx}; 112 } 113 114 renderDOM(attrs: TrackViewAttrs, children: m.Children) { 115 const { 116 scrollToOnCreate, 117 reorderable = false, 118 collapsible, 119 removable, 120 } = attrs; 121 const {node, renderer, height} = this; 122 123 const buttons = attrs.lite 124 ? [] 125 : [ 126 renderer?.track.getTrackShellButtons?.(), 127 (removable || node.removable) && this.renderCloseButton(), 128 // We don't want summary tracks to be pinned as they rarely have 129 // useful information. 130 !node.isSummary && this.renderPinButton(), 131 this.renderTrackMenuButton(), 132 this.renderAreaSelectionCheckbox(), 133 ]; 134 135 let scrollIntoView = false; 136 const tracks = this.trace.tracks; 137 if (tracks.scrollToTrackNodeId === node.id) { 138 tracks.scrollToTrackNodeId = undefined; 139 scrollIntoView = true; 140 } 141 142 function showTrackMoveErrorModal(msg: string) { 143 showModal({ 144 title: 'Error', 145 content: msg, 146 buttons: [{text: 'OK'}], 147 }); 148 } 149 150 return m( 151 TrackShell, 152 { 153 id: node.id, 154 title: node.title, 155 subtitle: renderer?.desc.subtitle, 156 ref: node.fullPath.join('/'), 157 heightPx: height, 158 error: renderer?.getError(), 159 chips: renderer?.desc.chips, 160 buttons, 161 scrollToOnCreate: scrollToOnCreate || scrollIntoView, 162 collapsible: collapsible && node.hasChildren, 163 collapsed: collapsible && node.collapsed, 164 highlight: this.isHighlighted(), 165 summary: node.isSummary, 166 reorderable, 167 depth: attrs.depth, 168 stickyTop: attrs.stickyTop, 169 pluginId: renderer?.desc.pluginId, 170 lite: attrs.lite, 171 onCollapsedChanged: () => { 172 node.hasChildren && node.toggleCollapsed(); 173 }, 174 onTrackContentMouseMove: (pos, bounds) => { 175 const timescale = this.getTimescaleForBounds(bounds); 176 renderer?.track.onMouseMove?.({ 177 ...pos, 178 timescale, 179 }); 180 raf.scheduleCanvasRedraw(); 181 }, 182 onTrackContentMouseOut: () => { 183 renderer?.track.onMouseOut?.(); 184 raf.scheduleCanvasRedraw(); 185 }, 186 onTrackContentClick: (pos, bounds) => { 187 const timescale = this.getTimescaleForBounds(bounds); 188 raf.scheduleCanvasRedraw(); 189 return ( 190 renderer?.track.onMouseClick?.({ 191 ...pos, 192 timescale, 193 }) ?? false 194 ); 195 }, 196 onupdate: () => { 197 renderer?.track.onFullRedraw?.(); 198 }, 199 onMoveBefore: (nodeId: string) => { 200 // We are the reference node (the one to be moved relative to), nodeId 201 // references the target node (the one to be moved) 202 const nodeToMove = node.workspace?.getTrackById(nodeId); 203 const targetNode = this.node.parent; 204 if (nodeToMove && targetNode) { 205 // Insert the target node before this one 206 const result = targetNode.addChildBefore(nodeToMove, node); 207 if (!result.ok) { 208 showTrackMoveErrorModal(result.error); 209 } 210 } 211 }, 212 onMoveInside: (nodeId: string) => { 213 // This one moves the node inside this node & expand it if it's not 214 // expanded already. 215 const nodeToMove = node.workspace?.getTrackById(nodeId); 216 if (nodeToMove) { 217 const result = this.node.addChildLast(nodeToMove); 218 if (result.ok) { 219 this.node.expand(); 220 } else { 221 showTrackMoveErrorModal(result.error); 222 } 223 } 224 }, 225 onMoveAfter: (nodeId: string) => { 226 // We are the reference node (the one to be moved relative to), nodeId 227 // references the target node (the one to be moved) 228 const nodeToMove = node.workspace?.getTrackById(nodeId); 229 const targetNode = this.node.parent; 230 if (nodeToMove && targetNode) { 231 // Insert the target node after this one 232 const result = targetNode.addChildAfter(nodeToMove, node); 233 if (!result.ok) { 234 showTrackMoveErrorModal(result.error); 235 } 236 } 237 }, 238 }, 239 children, 240 ); 241 } 242 243 drawCanvas( 244 ctx: CanvasRenderingContext2D, 245 rect: Rect2D, 246 visibleWindow: HighPrecisionTimeSpan, 247 perfStatsEnabled: boolean, 248 trackPerfStats: WeakMap<TrackNode, PerfStats>, 249 ) { 250 // For each track we rendered in view(), render it to the canvas. We know the 251 // vertical bounds, so we just need to combine it with the horizontal bounds 252 // and we're golden. 253 const {node, renderer, verticalBounds} = this; 254 255 if (node.isSummary && node.expanded) return; 256 if (renderer?.getError()) return; 257 258 const trackRect = new Rect2D({ 259 ...rect, 260 ...verticalBounds, 261 }); 262 263 // Track renderers expect to start rendering at (0, 0), so we need to 264 // translate the canvas and create a new timescale. 265 using _ = canvasSave(ctx); 266 canvasClip(ctx, trackRect); 267 ctx.translate(trackRect.left, trackRect.top); 268 269 const timescale = new TimeScale(visibleWindow, { 270 left: 0, 271 right: trackRect.width, 272 }); 273 274 const start = performance.now(); 275 276 node.uri && 277 renderer?.render({ 278 trackUri: node.uri, 279 visibleWindow, 280 size: trackRect, 281 resolution: calculateResolution(visibleWindow, trackRect.width), 282 ctx, 283 timescale, 284 }); 285 286 this.highlightIfTrackInAreaSelection(ctx, timescale, trackRect); 287 288 const renderTime = performance.now() - start; 289 290 if (!perfStatsEnabled) return; 291 this.updateAndRenderTrackPerfStats( 292 ctx, 293 trackRect, 294 renderTime, 295 trackPerfStats, 296 ); 297 } 298 299 private renderCloseButton() { 300 return m(Button, { 301 // TODO(stevegolton): It probably makes sense to only show this button 302 // when hovered for consistency with the other buttons, but hiding this 303 // button currently breaks the tests as we wait for the buttons to become 304 // available, enabled and visible before clicking on them. 305 // className: 'pf-visible-on-hover', 306 onclick: () => { 307 this.node.remove(); 308 }, 309 icon: Icons.Close, 310 title: 'Remove track', 311 compact: true, 312 }); 313 } 314 315 private renderPinButton(): m.Children { 316 const isPinned = this.node.isPinned; 317 return m(Button, { 318 className: classNames(!isPinned && 'pf-visible-on-hover'), 319 onclick: () => { 320 isPinned ? this.node.unpin() : this.node.pin(); 321 }, 322 icon: Icons.Pin, 323 iconFilled: isPinned, 324 title: isPinned ? 'Unpin' : 'Pin to top', 325 compact: true, 326 }); 327 } 328 329 private renderTrackMenuButton(): m.Children { 330 return m( 331 PopupMenu, 332 { 333 trigger: m(Button, { 334 className: 'pf-visible-on-hover', 335 icon: 'more_vert', 336 compact: true, 337 title: 'Track options', 338 }), 339 }, 340 // Putting these menu items inside a component means that view is only 341 // called when the popup is actually open, which can improve DOM 342 // render performance when we have thousands of tracks on screen. 343 m(TrackPopupMenu, { 344 trace: this.trace, 345 node: this.node, 346 descriptor: this.descriptor, 347 }), 348 ); 349 } 350 351 private getTimescaleForBounds(bounds: Bounds2D) { 352 const timeWindow = this.trace.timeline.visibleWindow; 353 return new TimeScale(timeWindow, { 354 left: 0, 355 right: bounds.right - bounds.left, 356 }); 357 } 358 359 private isHighlighted() { 360 const {trace, node} = this; 361 // The track should be highlighted if the current search result matches this 362 // track or one of its children. 363 const searchIndex = trace.search.resultIndex; 364 const searchResults = trace.search.searchResults; 365 366 if (searchIndex !== -1 && searchResults !== undefined) { 367 // using _ = autoTimer(); 368 const uri = searchResults.trackUris[searchIndex]; 369 // Highlight if this or any children match the search results 370 if (uri === node.uri || node.getTrackByUri(uri)) { 371 return true; 372 } 373 } 374 375 const curSelection = trace.selection; 376 if ( 377 curSelection.selection.kind === 'track' && 378 curSelection.selection.trackUri === node.uri 379 ) { 380 return true; 381 } 382 383 return false; 384 } 385 386 private renderAreaSelectionCheckbox(): m.Children { 387 const {trace, node} = this; 388 const selectionManager = trace.selection; 389 const selection = selectionManager.selection; 390 if (selection.kind === 'area') { 391 if (node.isSummary) { 392 const tracksWithUris = node.flatTracks.filter( 393 (t) => t.uri !== undefined, 394 ) as ReadonlyArray<RequiredField<TrackNode, 'uri'>>; 395 396 // Check if any nodes within are selected 397 const childTracksInSelection = tracksWithUris.map((t) => 398 selection.trackUris.includes(t.uri), 399 ); 400 401 function renderButton(icon: string, title: string) { 402 return m(Button, { 403 onclick: () => { 404 const uris = tracksWithUris.map((t) => t.uri); 405 selectionManager.toggleGroupAreaSelection(uris); 406 }, 407 compact: true, 408 icon, 409 title, 410 }); 411 } 412 413 if (childTracksInSelection.every((b) => b)) { 414 return renderButton( 415 Icons.Checkbox, 416 'Remove child tracks from selection', 417 ); 418 } else if (childTracksInSelection.some((b) => b)) { 419 return renderButton( 420 Icons.IndeterminateCheckbox, 421 'Add remaining child tracks to selection', 422 ); 423 } else { 424 return renderButton( 425 Icons.BlankCheckbox, 426 'Add child tracks to selection', 427 ); 428 } 429 } else { 430 const nodeUri = node.uri; 431 if (nodeUri) { 432 return ( 433 selection.kind === 'area' && 434 m(Button, { 435 onclick: () => { 436 selectionManager.toggleTrackAreaSelection(nodeUri); 437 }, 438 compact: true, 439 ...(selection.trackUris.includes(nodeUri) 440 ? {icon: Icons.Checkbox, title: 'Remove track'} 441 : {icon: Icons.BlankCheckbox, title: 'Add track to selection'}), 442 }) 443 ); 444 } 445 } 446 } 447 return undefined; 448 } 449 450 private highlightIfTrackInAreaSelection( 451 ctx: CanvasRenderingContext2D, 452 timescale: TimeScale, 453 size: Size2D, 454 ) { 455 const selection = this.trace.selection.selection; 456 457 if (selection.kind !== 'area') { 458 return; 459 } 460 461 let selected = false; 462 if (this.node.isSummary) { 463 // Summary tracks cannot themselves be area-selected. So, as a visual aid, 464 // if this track is a summary track and some of its children are in the 465 // area selecion, highlight this track as if it were in the area 466 // selection too. 467 selected = selection.trackUris.some((uri) => 468 this.node.getTrackByUri(uri), 469 ); 470 } else { 471 // For non-summary tracks, simply highlight this track if it's in the area 472 // selection. 473 if (this.node.uri !== undefined) { 474 selected = selection.trackUris.includes(this.node.uri); 475 } 476 } 477 478 if (selected) { 479 const selectedAreaDuration = selection.end - selection.start; 480 ctx.fillStyle = SELECTION_FILL_COLOR; 481 ctx.fillRect( 482 timescale.timeToPx(selection.start), 483 0, 484 timescale.durationToPx(selectedAreaDuration), 485 size.height, 486 ); 487 } 488 } 489 490 private updateAndRenderTrackPerfStats( 491 ctx: CanvasRenderingContext2D, 492 size: Size2D, 493 renderTime: number, 494 trackPerfStats: WeakMap<TrackNode, PerfStats>, 495 ) { 496 let renderStats = trackPerfStats.get(this.node); 497 if (renderStats === undefined) { 498 renderStats = new PerfStats(); 499 trackPerfStats.set(this.node, renderStats); 500 } 501 renderStats.addValue(renderTime); 502 503 // Draw a green box around the whole track 504 ctx.strokeStyle = 'rgba(69, 187, 73, 0.5)'; 505 const lineWidth = 1; 506 ctx.lineWidth = lineWidth; 507 ctx.strokeRect( 508 lineWidth / 2, 509 lineWidth / 2, 510 size.width - lineWidth, 511 size.height - lineWidth, 512 ); 513 514 const statW = 300; 515 ctx.font = '10px sans-serif'; 516 ctx.textAlign = 'start'; 517 ctx.textBaseline = 'alphabetic'; 518 ctx.direction = 'inherit'; 519 ctx.fillStyle = 'hsl(97, 100%, 96%)'; 520 ctx.fillRect(size.width - statW, size.height - 20, statW, 20); 521 ctx.fillStyle = 'hsla(122, 77%, 22%)'; 522 const statStr = `Track ${this.node.id} | ` + runningStatStr(renderStats); 523 ctx.fillText(statStr, size.width - statW, size.height - 10); 524 } 525} 526 527interface TrackPopupMenuAttrs { 528 readonly trace: Trace; 529 readonly node: TrackNode; 530 readonly descriptor?: Track; 531} 532 533// This component contains the track menu items which are displayed inside a 534// popup menu on each track. They're in a component to avoid having to render 535// them every single mithril cycle. 536const TrackPopupMenu = { 537 view({attrs}: m.Vnode<TrackPopupMenuAttrs>) { 538 return [ 539 m(MenuItem, { 540 label: 'Select track', 541 disabled: !attrs.node.uri, 542 onclick: () => { 543 attrs.trace.selection.selectTrack(attrs.node.uri!); 544 }, 545 title: attrs.node.uri 546 ? 'Select track' 547 : 'Track has no URI and cannot be selected', 548 }), 549 m( 550 MenuItem, 551 {label: 'Track details'}, 552 renderTrackDetailsMenu(attrs.node, attrs.descriptor), 553 ), 554 m(MenuDivider), 555 m( 556 MenuItem, 557 {label: 'Copy to workspace'}, 558 attrs.trace.workspaces.all.map((ws) => 559 m(MenuItem, { 560 label: ws.title, 561 disabled: !ws.userEditable, 562 onclick: () => copyToWorkspace(attrs.trace, attrs.node, ws), 563 }), 564 ), 565 m(MenuDivider), 566 m(MenuItem, { 567 label: 'New workspace...', 568 onclick: () => copyToWorkspace(attrs.trace, attrs.node), 569 }), 570 ), 571 m( 572 MenuItem, 573 {label: 'Copy & switch to workspace'}, 574 attrs.trace.workspaces.all.map((ws) => 575 m(MenuItem, { 576 label: ws.title, 577 disabled: !ws.userEditable, 578 onclick: async () => { 579 copyToWorkspace(attrs.trace, attrs.node, ws); 580 attrs.trace.workspaces.switchWorkspace(ws); 581 }, 582 }), 583 ), 584 m(MenuDivider), 585 m(MenuItem, { 586 label: 'New workspace...', 587 onclick: async () => { 588 const ws = copyToWorkspace(attrs.trace, attrs.node); 589 attrs.trace.workspaces.switchWorkspace(ws); 590 }, 591 }), 592 ), 593 ]; 594 }, 595}; 596 597function copyToWorkspace(trace: Trace, node: TrackNode, ws?: Workspace) { 598 // If no workspace provided, create a new one. 599 if (!ws) { 600 ws = trace.workspaces.createEmptyWorkspace('Untitled Workspace'); 601 } 602 // Deep clone makes sure all group's content is also copied 603 const newNode = node.clone(true); 604 newNode.removable = true; 605 ws.addChildLast(newNode); 606 return ws; 607} 608 609function renderTrackDetailsMenu(node: TrackNode, descriptor?: Track) { 610 let parent = node.parent; 611 let fullPath: m.ChildArray = [node.title]; 612 while (parent && parent instanceof TrackNode) { 613 fullPath = [parent.title, ' \u2023 ', ...fullPath]; 614 parent = parent.parent; 615 } 616 617 const query = descriptor?.track.getDataset?.()?.query(); 618 619 return m( 620 '.pf-track__track-details-popup', 621 m( 622 Tree, 623 m(TreeNode, {left: 'Track Node ID', right: node.id}), 624 m(TreeNode, {left: 'Collapsed', right: `${node.collapsed}`}), 625 m(TreeNode, {left: 'URI', right: node.uri}), 626 m(TreeNode, { 627 left: 'Is Summary Track', 628 right: `${node.isSummary}`, 629 }), 630 m(TreeNode, { 631 left: 'SortOrder', 632 right: node.sortOrder ?? '0 (undefined)', 633 }), 634 m(TreeNode, {left: 'Path', right: fullPath}), 635 m(TreeNode, {left: 'Title', right: node.title}), 636 m(TreeNode, { 637 left: 'Workspace', 638 right: node.workspace?.title ?? '[no workspace]', 639 }), 640 descriptor && 641 m(TreeNode, { 642 left: 'Plugin ID', 643 right: descriptor.pluginId, 644 }), 645 query && 646 m(TreeNode, { 647 left: 'Track Query', 648 right: m( 649 Anchor, 650 { 651 onclick: () => { 652 showModal({ 653 title: 'Query for track', 654 content: m('pre', query), 655 buttons: [ 656 { 657 text: 'Copy to clipboard', 658 action: () => copyToClipboard(query), 659 }, 660 ], 661 }); 662 }, 663 }, 664 'Show query', 665 ), 666 }), 667 descriptor && 668 m( 669 TreeNode, 670 {left: 'Tags'}, 671 descriptor.tags && 672 Object.entries(descriptor.tags).map(([key, value]) => { 673 return m(TreeNode, {left: key, right: value?.toString()}); 674 }), 675 ), 676 ), 677 ); 678} 679