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 m from 'mithril'; 16import {canvasClip, canvasSave} from '../base/canvas_utils'; 17import {classNames} from '../base/classnames'; 18import {Bounds2D, Size2D, VerticalBounds} from '../base/geom'; 19import {Icons} from '../base/semantic_icons'; 20import {TimeScale} from '../base/time_scale'; 21import {RequiredField} from '../base/utils'; 22import {calculateResolution} from '../common/resolution'; 23import {featureFlags} from '../core/feature_flags'; 24import {TrackRenderer} from '../core/track_manager'; 25import {TrackDescriptor, TrackRenderContext} from '../public/track'; 26import {TrackNode} from '../public/workspace'; 27import {Button} from '../widgets/button'; 28import {Popup, PopupPosition} from '../widgets/popup'; 29import {Tree, TreeNode} from '../widgets/tree'; 30import {SELECTION_FILL_COLOR, TRACK_SHELL_WIDTH} from './css_constants'; 31import {Panel} from './panel_container'; 32import {TrackWidget} from '../widgets/track_widget'; 33import {raf} from '../core/raf_scheduler'; 34import {Intent} from '../widgets/common'; 35import {TraceImpl} from '../core/trace_impl'; 36 37const SHOW_TRACK_DETAILS_BUTTON = featureFlags.register({ 38 id: 'showTrackDetailsButton', 39 name: 'Show track details button', 40 description: 'Show track details button in track shells.', 41 defaultValue: false, 42}); 43 44// Default height of a track element that has no track, or is collapsed. 45// Note: This is designed to roughly match the height of a cpu slice track. 46export const DEFAULT_TRACK_HEIGHT_PX = 30; 47 48interface TrackPanelAttrs { 49 readonly trace: TraceImpl; 50 readonly node: TrackNode; 51 readonly indentationLevel: number; 52 readonly trackRenderer?: TrackRenderer; 53 readonly revealOnCreate?: boolean; 54 readonly topOffsetPx: number; 55 readonly reorderable?: boolean; 56} 57 58export class TrackPanel implements Panel { 59 readonly kind = 'panel'; 60 readonly selectable = true; 61 readonly trackNode?: TrackNode; 62 63 private readonly attrs: TrackPanelAttrs; 64 65 constructor(attrs: TrackPanelAttrs) { 66 this.attrs = attrs; 67 this.trackNode = attrs.node; 68 } 69 70 get heightPx(): number { 71 const {trackRenderer, node} = this.attrs; 72 73 // If the node is a summary track and is expanded, shrink it to save 74 // vertical real estate). 75 if (node.isSummary && node.expanded) return DEFAULT_TRACK_HEIGHT_PX; 76 77 // Otherwise return the height of the track, if we have one. 78 return trackRenderer?.track.getHeight() ?? DEFAULT_TRACK_HEIGHT_PX; 79 } 80 81 render(): m.Children { 82 const { 83 node, 84 indentationLevel, 85 trackRenderer, 86 revealOnCreate, 87 topOffsetPx, 88 reorderable = false, 89 } = this.attrs; 90 91 const error = trackRenderer?.getError(); 92 93 const buttons = [ 94 SHOW_TRACK_DETAILS_BUTTON.get() && 95 renderTrackDetailsButton(node, trackRenderer?.desc), 96 trackRenderer?.track.getTrackShellButtons?.(), 97 node.removable && renderCloseButton(node), 98 // We don't want summary tracks to be pinned as they rarely have 99 // useful information. 100 !node.isSummary && renderPinButton(node), 101 this.renderAreaSelectionCheckbox(node), 102 error && renderCrashButton(error, trackRenderer?.desc.pluginId), 103 ]; 104 105 let scrollIntoView = false; 106 const tracks = this.attrs.trace.tracks; 107 if (tracks.scrollToTrackNodeId === node.id) { 108 tracks.scrollToTrackNodeId = undefined; 109 scrollIntoView = true; 110 } 111 112 return m(TrackWidget, { 113 id: node.id, 114 title: node.title, 115 path: node.fullPath.join('/'), 116 heightPx: this.heightPx, 117 error: Boolean(trackRenderer?.getError()), 118 chips: trackRenderer?.desc.chips, 119 indentationLevel, 120 topOffsetPx, 121 buttons, 122 revealOnCreate: revealOnCreate || scrollIntoView, 123 collapsible: node.hasChildren, 124 collapsed: node.collapsed, 125 highlight: this.isHighlighted(node), 126 isSummary: node.isSummary, 127 reorderable, 128 onToggleCollapsed: () => { 129 node.hasChildren && node.toggleCollapsed(); 130 }, 131 onTrackContentMouseMove: (pos, bounds) => { 132 const timescale = this.getTimescaleForBounds(bounds); 133 trackRenderer?.track.onMouseMove?.({ 134 ...pos, 135 timescale, 136 }); 137 raf.scheduleCanvasRedraw(); 138 }, 139 onTrackContentMouseOut: () => { 140 trackRenderer?.track.onMouseOut?.(); 141 raf.scheduleCanvasRedraw(); 142 }, 143 onTrackContentClick: (pos, bounds) => { 144 const timescale = this.getTimescaleForBounds(bounds); 145 raf.scheduleCanvasRedraw(); 146 return ( 147 trackRenderer?.track.onMouseClick?.({ 148 ...pos, 149 timescale, 150 }) ?? false 151 ); 152 }, 153 onupdate: () => { 154 trackRenderer?.track.onFullRedraw?.(); 155 }, 156 onMoveBefore: (nodeId: string) => { 157 const targetNode = node.workspace?.getTrackById(nodeId); 158 if (targetNode !== undefined) { 159 // Insert the target node before this one 160 targetNode.parent?.addChildBefore(targetNode, node); 161 } 162 }, 163 onMoveAfter: (nodeId: string) => { 164 const targetNode = node.workspace?.getTrackById(nodeId); 165 if (targetNode !== undefined) { 166 // Insert the target node after this one 167 targetNode.parent?.addChildAfter(targetNode, node); 168 } 169 }, 170 }); 171 } 172 173 renderCanvas(ctx: CanvasRenderingContext2D, size: Size2D) { 174 const {trackRenderer: tr, node} = this.attrs; 175 176 // Don't render if expanded and isSummary 177 if (node.isSummary && node.expanded) { 178 return; 179 } 180 181 const trackSize = { 182 width: size.width - TRACK_SHELL_WIDTH, 183 height: size.height, 184 }; 185 186 using _ = canvasSave(ctx); 187 ctx.translate(TRACK_SHELL_WIDTH, 0); 188 canvasClip(ctx, 0, 0, trackSize.width, trackSize.height); 189 190 const visibleWindow = this.attrs.trace.timeline.visibleWindow; 191 const timescale = new TimeScale(visibleWindow, { 192 left: 0, 193 right: trackSize.width, 194 }); 195 196 if (tr) { 197 if (!tr.getError()) { 198 const trackRenderCtx: TrackRenderContext = { 199 trackUri: tr.desc.uri, 200 visibleWindow, 201 size: trackSize, 202 resolution: calculateResolution(visibleWindow, trackSize.width), 203 ctx, 204 timescale, 205 }; 206 tr.render(trackRenderCtx); 207 } 208 } 209 210 this.highlightIfTrackInAreaSelection(ctx, timescale, node, trackSize); 211 } 212 213 getSliceVerticalBounds(depth: number): VerticalBounds | undefined { 214 if (this.attrs.trackRenderer === undefined) { 215 return undefined; 216 } 217 return this.attrs.trackRenderer.track.getSliceVerticalBounds?.(depth); 218 } 219 220 private getTimescaleForBounds(bounds: Bounds2D) { 221 const timeWindow = this.attrs.trace.timeline.visibleWindow; 222 return new TimeScale(timeWindow, { 223 left: 0, 224 right: bounds.right - bounds.left, 225 }); 226 } 227 228 private isHighlighted(node: TrackNode) { 229 // The track should be highlighted if the current search result matches this 230 // track or one of its children. 231 const searchIndex = this.attrs.trace.search.resultIndex; 232 const searchResults = this.attrs.trace.search.searchResults; 233 234 if (searchIndex !== -1 && searchResults !== undefined) { 235 const uri = searchResults.trackUris[searchIndex]; 236 // Highlight if this or any children match the search results 237 if ( 238 uri === node.uri || 239 node.flatTracksOrdered.find((t) => t.uri === uri) 240 ) { 241 return true; 242 } 243 } 244 245 const curSelection = this.attrs.trace.selection; 246 if ( 247 curSelection.selection.kind === 'track' && 248 curSelection.selection.trackUri === node.uri 249 ) { 250 return true; 251 } 252 253 return false; 254 } 255 256 private highlightIfTrackInAreaSelection( 257 ctx: CanvasRenderingContext2D, 258 timescale: TimeScale, 259 node: TrackNode, 260 size: Size2D, 261 ) { 262 const selection = this.attrs.trace.selection.selection; 263 if (selection.kind !== 'area') { 264 return; 265 } 266 267 const tracksWithUris = node.flatTracks.filter( 268 (t) => t.uri !== undefined, 269 ) as ReadonlyArray<RequiredField<TrackNode, 'uri'>>; 270 271 let selected = false; 272 if (node.isSummary) { 273 selected = tracksWithUris.some((track) => 274 selection.trackUris.includes(track.uri), 275 ); 276 } else { 277 if (node.uri) { 278 selected = selection.trackUris.includes(node.uri); 279 } 280 } 281 282 if (selected) { 283 const selectedAreaDuration = selection.end - selection.start; 284 ctx.fillStyle = SELECTION_FILL_COLOR; 285 ctx.fillRect( 286 timescale.timeToPx(selection.start), 287 0, 288 timescale.durationToPx(selectedAreaDuration), 289 size.height, 290 ); 291 } 292 } 293 294 private renderAreaSelectionCheckbox(node: TrackNode): m.Children { 295 const selectionManager = this.attrs.trace.selection; 296 const selection = selectionManager.selection; 297 if (selection.kind === 'area') { 298 if (node.isSummary) { 299 const tracksWithUris = node.flatTracks.filter( 300 (t) => t.uri !== undefined, 301 ) as ReadonlyArray<RequiredField<TrackNode, 'uri'>>; 302 // Check if any nodes within are selected 303 const childTracksInSelection = tracksWithUris.map((t) => 304 selection.trackUris.includes(t.uri), 305 ); 306 if (childTracksInSelection.every((b) => b)) { 307 return m(Button, { 308 onclick: (e: MouseEvent) => { 309 const uris = tracksWithUris.map((t) => t.uri); 310 selectionManager.toggleGroupAreaSelection(uris); 311 e.stopPropagation(); 312 }, 313 compact: true, 314 icon: Icons.Checkbox, 315 title: 'Remove child tracks from selection', 316 }); 317 } else if (childTracksInSelection.some((b) => b)) { 318 return m(Button, { 319 onclick: (e: MouseEvent) => { 320 const uris = tracksWithUris.map((t) => t.uri); 321 selectionManager.toggleGroupAreaSelection(uris); 322 e.stopPropagation(); 323 }, 324 compact: true, 325 icon: Icons.IndeterminateCheckbox, 326 title: 'Add remaining child tracks to selection', 327 }); 328 } else { 329 return m(Button, { 330 onclick: (e: MouseEvent) => { 331 const uris = tracksWithUris.map((t) => t.uri); 332 selectionManager.toggleGroupAreaSelection(uris); 333 e.stopPropagation(); 334 }, 335 compact: true, 336 icon: Icons.BlankCheckbox, 337 title: 'Add child tracks to selection', 338 }); 339 } 340 } else { 341 const nodeUri = node.uri; 342 if (nodeUri) { 343 return ( 344 selection.kind === 'area' && 345 m(Button, { 346 onclick: (e: MouseEvent) => { 347 selectionManager.toggleTrackAreaSelection(nodeUri); 348 e.stopPropagation(); 349 }, 350 compact: true, 351 ...(selection.trackUris.includes(nodeUri) 352 ? {icon: Icons.Checkbox, title: 'Remove track'} 353 : {icon: Icons.BlankCheckbox, title: 'Add track to selection'}), 354 }) 355 ); 356 } 357 } 358 } 359 return undefined; 360 } 361} 362 363function renderCrashButton(error: Error, pluginId?: string) { 364 return m( 365 Popup, 366 { 367 trigger: m(Button, { 368 icon: Icons.Crashed, 369 compact: true, 370 }), 371 }, 372 m( 373 '.pf-track-crash-popup', 374 m('span', 'This track has crashed.'), 375 pluginId && m('span', `Owning plugin: ${pluginId}`), 376 m(Button, { 377 label: 'View & Report Crash', 378 intent: Intent.Primary, 379 className: Popup.DISMISS_POPUP_GROUP_CLASS, 380 onclick: () => { 381 throw error; 382 }, 383 }), 384 // TODO(stevegolton): In the future we should provide a quick way to 385 // disable the plugin, or provide a link to the plugin page, but this 386 // relies on the plugin page being fully functional. 387 ), 388 ); 389} 390 391function renderCloseButton(node: TrackNode) { 392 return m(Button, { 393 onclick: (e) => { 394 node.remove(); 395 e.stopPropagation(); 396 }, 397 icon: Icons.Close, 398 title: 'Close track', 399 compact: true, 400 }); 401} 402 403function renderPinButton(node: TrackNode): m.Children { 404 const isPinned = node.isPinned; 405 return m(Button, { 406 className: classNames(!isPinned && 'pf-visible-on-hover'), 407 onclick: (e) => { 408 isPinned ? node.unpin() : node.pin(); 409 e.stopPropagation(); 410 }, 411 icon: Icons.Pin, 412 iconFilled: isPinned, 413 title: isPinned ? 'Unpin' : 'Pin to top', 414 compact: true, 415 }); 416} 417 418function renderTrackDetailsButton( 419 node: TrackNode, 420 td?: TrackDescriptor, 421): m.Children { 422 let parent = node.parent; 423 let fullPath: m.ChildArray = [node.title]; 424 while (parent && parent instanceof TrackNode) { 425 fullPath = [parent.title, ' \u2023 ', ...fullPath]; 426 parent = parent.parent; 427 } 428 return m( 429 Popup, 430 { 431 trigger: m(Button, { 432 className: 'pf-visible-on-hover', 433 icon: 'info', 434 title: 'Show track details', 435 compact: true, 436 }), 437 position: PopupPosition.Bottom, 438 }, 439 m( 440 '.pf-track-details-dropdown', 441 m( 442 Tree, 443 m(TreeNode, {left: 'Track Node ID', right: node.id}), 444 m(TreeNode, {left: 'Collapsed', right: `${node.collapsed}`}), 445 m(TreeNode, {left: 'URI', right: node.uri}), 446 m(TreeNode, {left: 'Is Summary Track', right: `${node.isSummary}`}), 447 m(TreeNode, { 448 left: 'SortOrder', 449 right: node.sortOrder ?? '0 (undefined)', 450 }), 451 m(TreeNode, {left: 'Path', right: fullPath}), 452 m(TreeNode, {left: 'Title', right: node.title}), 453 m(TreeNode, { 454 left: 'Workspace', 455 right: node.workspace?.title ?? '[no workspace]', 456 }), 457 td && m(TreeNode, {left: 'Plugin ID', right: td.pluginId}), 458 td && 459 m( 460 TreeNode, 461 {left: 'Tags'}, 462 td.tags && 463 Object.entries(td.tags).map(([key, value]) => { 464 return m(TreeNode, {left: key, right: value?.toString()}); 465 }), 466 ), 467 ), 468 ), 469 ); 470} 471