1// Copyright (C) 2019 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use size 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} from '../../base/canvas_utils'; 17import {currentTargetOffset, findRef} from '../../base/dom_utils'; 18import {Size2D} from '../../base/geom'; 19import {assertUnreachable} from '../../base/logging'; 20import {Icons} from '../../base/semantic_icons'; 21import {TimeScale} from '../../base/time_scale'; 22import {randomColor} from '../../components/colorizer'; 23import {raf} from '../../core/raf_scheduler'; 24import {TraceImpl} from '../../core/trace_impl'; 25import {Note, SpanNote} from '../../public/note'; 26import {Button, ButtonBar} from '../../widgets/button'; 27import {MenuDivider, MenuItem, PopupMenu} from '../../widgets/menu'; 28import {Select} from '../../widgets/select'; 29import {TRACK_SHELL_WIDTH} from '../css_constants'; 30import {generateTicks, getMaxMajorTicks, TickType} from './gridline_helper'; 31import {TextInput} from '../../widgets/text_input'; 32import {Popup} from '../../widgets/popup'; 33import {TrackNode, Workspace} from '../../public/workspace'; 34import {AreaSelection, Selection} from '../../public/selection'; 35import {MultiSelectOption, PopupMultiSelect} from '../../widgets/multiselect'; 36 37const FLAG_WIDTH = 16; 38const AREA_TRIANGLE_WIDTH = 10; 39const FLAG = `\uE153`; 40 41function toSummary(s: string) { 42 const newlineIndex = s.indexOf('\n') > 0 ? s.indexOf('\n') : s.length; 43 return s.slice(0, Math.min(newlineIndex, s.length, 16)); 44} 45 46function getStartTimestamp(note: Note | SpanNote) { 47 const noteType = note.noteType; 48 switch (noteType) { 49 case 'SPAN': 50 return note.start; 51 case 'DEFAULT': 52 return note.timestamp; 53 default: 54 assertUnreachable(noteType); 55 } 56} 57 58const FILTER_TEXT_BOX_REF = 'filter-text-box'; 59 60export class NotesPanel { 61 private readonly trace: TraceImpl; 62 private timescale?: TimeScale; // The timescale from the last render() 63 private hoveredX: null | number = null; 64 private mouseDragging = false; 65 readonly height = 20; 66 67 constructor(trace: TraceImpl) { 68 this.trace = trace; 69 } 70 71 render(): m.Children { 72 const allCollapsed = this.trace.workspace.flatTracks.every( 73 (n) => n.collapsed, 74 ); 75 76 const workspaces = this.trace.workspaces; 77 const selection = this.trace.selection.selection; 78 79 return m( 80 '', 81 { 82 style: {height: `${this.height}px`}, 83 onmousedown: () => { 84 // If the user clicks & drags, very likely they just want to measure 85 // the time horizontally, not set a flag. This debouncing is done to 86 // avoid setting accidental flags like measuring the time on the brush 87 // timeline. 88 this.mouseDragging = false; 89 }, 90 onclick: (e: MouseEvent) => { 91 if (!this.mouseDragging) { 92 const x = currentTargetOffset(e).x - TRACK_SHELL_WIDTH; 93 this.onClick(x); 94 e.stopPropagation(); 95 } 96 }, 97 onmousemove: (e: MouseEvent) => { 98 this.mouseDragging = true; 99 this.hoveredX = currentTargetOffset(e).x - TRACK_SHELL_WIDTH; 100 raf.scheduleCanvasRedraw(); 101 }, 102 onmouseenter: (e: MouseEvent) => { 103 this.hoveredX = currentTargetOffset(e).x - TRACK_SHELL_WIDTH; 104 raf.scheduleCanvasRedraw(); 105 }, 106 onmouseout: () => { 107 this.hoveredX = null; 108 this.trace.timeline.hoveredNoteTimestamp = undefined; 109 }, 110 }, 111 m( 112 ButtonBar, 113 {className: 'pf-timeline-toolbar'}, 114 m(Button, { 115 onclick: (e: Event) => { 116 e.preventDefault(); 117 if (allCollapsed) { 118 this.trace.commands.runCommand( 119 'perfetto.CoreCommands#ExpandAllGroups', 120 ); 121 } else { 122 this.trace.commands.runCommand( 123 'perfetto.CoreCommands#CollapseAllGroups', 124 ); 125 } 126 }, 127 title: allCollapsed ? 'Expand all' : 'Collapse all', 128 icon: allCollapsed ? 'unfold_more' : 'unfold_less', 129 compact: true, 130 }), 131 m(Button, { 132 onclick: (e: Event) => { 133 e.preventDefault(); 134 this.trace.workspace.pinnedTracks.forEach((t) => 135 this.trace.workspace.unpinTrack(t), 136 ); 137 }, 138 title: 'Clear all pinned tracks', 139 icon: 'clear_all', 140 compact: true, 141 }), 142 this.renderTrackFilter(), 143 m( 144 Select, 145 { 146 className: 'pf-timeline-toolbar__workspace-selector', 147 onchange: async (e) => { 148 const value = (e.target as HTMLSelectElement).value; 149 if (value === 'new-workspace') { 150 const ws = 151 workspaces.createEmptyWorkspace('Untitled Workspace'); 152 workspaces.switchWorkspace(ws); 153 } else { 154 const ws = workspaces.all.find(({id}) => id === value); 155 ws && this.trace?.workspaces.switchWorkspace(ws); 156 } 157 }, 158 }, 159 workspaces.all 160 .map((ws) => { 161 return m('option', { 162 value: `${ws.id}`, 163 label: ws.title, 164 selected: ws === this.trace?.workspace, 165 }); 166 }) 167 .concat([ 168 m('option', { 169 value: 'new-workspace', 170 label: 'New workspace...', 171 }), 172 ]), 173 ), 174 m( 175 PopupMenu, 176 { 177 trigger: m(Button, { 178 icon: 'more_vert', 179 title: 'Workspace options', 180 compact: true, 181 }), 182 }, 183 this.renderCopySelectedTracksToWorkspace(selection), 184 m(MenuDivider), 185 this.renderNewGroupButton(), 186 m(MenuDivider), 187 m(MenuItem, { 188 icon: 'edit', 189 label: 'Rename current workspace', 190 disabled: !this.trace.workspace.userEditable, 191 title: this.trace.workspace.userEditable 192 ? 'Create new group' 193 : 'This workspace is not editable - please create a new workspace if you wish to modify it', 194 onclick: async () => { 195 const newName = await this.trace.omnibox.prompt( 196 'Enter a new name...', 197 ); 198 if (newName) { 199 workspaces.currentWorkspace.title = newName; 200 } 201 }, 202 }), 203 m(MenuItem, { 204 icon: Icons.Delete, 205 label: 'Delete current workspace', 206 disabled: !this.trace.workspace.userEditable, 207 title: this.trace.workspace.userEditable 208 ? 'Create new group' 209 : 'This workspace is not editable - please create a new workspace if you wish to modify it', 210 onclick: () => { 211 workspaces.removeWorkspace(workspaces.currentWorkspace); 212 }, 213 }), 214 ), 215 ), 216 ); 217 } 218 219 private renderTrackFilter() { 220 const trackFilters = this.trace.tracks.filters; 221 222 return m( 223 Popup, 224 { 225 trigger: m(Button, { 226 icon: 'filter_alt', 227 title: 'Track filter', 228 compact: true, 229 iconFilled: trackFilters.areFiltersSet(), 230 }), 231 }, 232 m( 233 'form.pf-track-filter', 234 { 235 oncreate({dom}) { 236 // Focus & select text box when the popup opens. 237 const input = findRef(dom, FILTER_TEXT_BOX_REF) as HTMLInputElement; 238 input.focus(); 239 input.select(); 240 }, 241 }, 242 m( 243 '.pf-track-filter__row', 244 m('label', {for: 'filter-name'}, 'Filter by name'), 245 m(TextInput, { 246 ref: FILTER_TEXT_BOX_REF, 247 id: 'filter-name', 248 placeholder: 'Filter by name...', 249 title: 'Filter by name (comma separated terms)', 250 value: trackFilters.nameFilter, 251 oninput: (e: Event) => { 252 const value = (e.target as HTMLInputElement).value; 253 trackFilters.nameFilter = value; 254 }, 255 }), 256 ), 257 this.trace.tracks.trackFilterCriteria.map((filter) => { 258 return m( 259 '.pf-track-filter__row', 260 m('label', 'Filter by ', filter.name), 261 m(PopupMultiSelect, { 262 label: filter.name, 263 showNumSelected: true, 264 // It usually doesn't make sense to select all filters - if users 265 // want to pass all they should just remove the filters instead. 266 showSelectAllButton: false, 267 onChange: (diff) => { 268 for (const {id, checked} of diff) { 269 if (checked) { 270 // Add the filter option to the criteria. 271 const criteriaFilters = trackFilters.criteriaFilters.get( 272 filter.name, 273 ); 274 if (criteriaFilters) { 275 criteriaFilters.push(id); 276 } else { 277 trackFilters.criteriaFilters.set(filter.name, [id]); 278 } 279 } else { 280 // Remove the filter option from the criteria. 281 const filterOptions = trackFilters.criteriaFilters.get( 282 filter.name, 283 ); 284 285 if (!filterOptions) continue; 286 const newOptions = filterOptions.filter((f) => f !== id); 287 if (newOptions.length === 0) { 288 trackFilters.criteriaFilters.delete(filter.name); 289 } else { 290 trackFilters.criteriaFilters.set(filter.name, newOptions); 291 } 292 } 293 } 294 }, 295 options: filter.options 296 .map((o): MultiSelectOption => { 297 const filterOptions = trackFilters.criteriaFilters.get( 298 filter.name, 299 ); 300 const checked = Boolean( 301 filterOptions && filterOptions.includes(o.key), 302 ); 303 return {id: o.key, name: o.label, checked}; 304 }) 305 .filter((f) => f.name !== ''), 306 }), 307 ); 308 }), 309 m(Button, { 310 type: 'reset', 311 label: 'Clear All Filters', 312 icon: 'filter_alt_off', 313 onclick: () => { 314 trackFilters.clearAll(); 315 }, 316 }), 317 ), 318 ); 319 } 320 321 private renderNewGroupButton() { 322 return m(MenuItem, { 323 icon: 'create_new_folder', 324 label: 'Create new group track', 325 disabled: !this.trace.workspace.userEditable, 326 title: this.trace.workspace.userEditable 327 ? 'Create new group' 328 : 'This workspace is not editable - please create a new workspace if you wish to modify it', 329 onclick: async () => { 330 const result = await this.trace.omnibox.prompt('Group name...'); 331 if (result) { 332 const group = new TrackNode({title: result, isSummary: true}); 333 this.trace.workspace.addChildLast(group); 334 } 335 }, 336 }); 337 } 338 339 private renderCopySelectedTracksToWorkspace(selection: Selection) { 340 const isArea = selection.kind === 'area'; 341 return [ 342 m( 343 MenuItem, 344 { 345 label: 'Copy selected tracks to workspace', 346 disabled: !isArea, 347 title: isArea 348 ? 'Copy selected tracks to workspace' 349 : 'Please create an area selection to copy tracks', 350 }, 351 this.trace.workspaces.all.map((ws) => 352 m(MenuItem, { 353 label: ws.title, 354 disabled: !ws.userEditable, 355 onclick: isArea 356 ? () => this.copySelectedToWorkspace(ws, selection) 357 : undefined, 358 }), 359 ), 360 m(MenuDivider), 361 m(MenuItem, { 362 label: 'New workspace...', 363 onclick: isArea 364 ? () => this.copySelectedToWorkspace(undefined, selection) 365 : undefined, 366 }), 367 ), 368 m( 369 MenuItem, 370 { 371 label: 'Copy selected tracks & switch to workspace', 372 disabled: !isArea, 373 title: isArea 374 ? 'Copy selected tracks to workspace and switch to that workspace' 375 : 'Please create an area selection to copy tracks', 376 }, 377 this.trace.workspaces.all.map((ws) => 378 m(MenuItem, { 379 label: ws.title, 380 disabled: !ws.userEditable, 381 onclick: isArea 382 ? async () => { 383 this.copySelectedToWorkspace(ws, selection); 384 this.trace.workspaces.switchWorkspace(ws); 385 } 386 : undefined, 387 }), 388 ), 389 m(MenuDivider), 390 m(MenuItem, { 391 label: 'New workspace...', 392 onclick: isArea 393 ? async () => { 394 const ws = this.copySelectedToWorkspace(undefined, selection); 395 this.trace.workspaces.switchWorkspace(ws); 396 } 397 : undefined, 398 }), 399 ), 400 ]; 401 } 402 403 private copySelectedToWorkspace( 404 ws: Workspace | undefined, 405 selection: AreaSelection, 406 ) { 407 // If no workspace provided, create a new one. 408 if (!ws) { 409 ws = this.trace.workspaces.createEmptyWorkspace('Untitled Workspace'); 410 } 411 for (const track of selection.tracks) { 412 const node = this.trace.workspace.getTrackByUri(track.uri); 413 if (!node) continue; 414 const newNode = node.clone(); 415 ws.addChildLast(newNode); 416 } 417 return ws; 418 } 419 420 renderCanvas(ctx: CanvasRenderingContext2D, size: Size2D) { 421 ctx.fillStyle = '#999'; 422 ctx.fillRect(TRACK_SHELL_WIDTH - 1, 0, 1, size.height); 423 424 const trackSize = {...size, width: size.width - TRACK_SHELL_WIDTH}; 425 426 ctx.save(); 427 ctx.translate(TRACK_SHELL_WIDTH, 0); 428 canvasClip(ctx, 0, 0, trackSize.width, trackSize.height); 429 this.renderPanel(ctx, trackSize); 430 ctx.restore(); 431 } 432 433 private renderPanel(ctx: CanvasRenderingContext2D, size: Size2D): void { 434 let aNoteIsHovered = false; 435 436 const visibleWindow = this.trace.timeline.visibleWindow; 437 const timescale = new TimeScale(visibleWindow, { 438 left: 0, 439 right: size.width, 440 }); 441 const timespan = visibleWindow.toTimeSpan(); 442 443 this.timescale = timescale; 444 445 if (size.width > 0 && timespan.duration > 0n) { 446 const maxMajorTicks = getMaxMajorTicks(size.width); 447 const offset = this.trace.timeline.timestampOffset(); 448 const tickGen = generateTicks(timespan, maxMajorTicks, offset); 449 for (const {type, time} of tickGen) { 450 const px = Math.floor(timescale.timeToPx(time)); 451 if (type === TickType.MAJOR) { 452 ctx.fillRect(px, 0, 1, size.height); 453 } 454 } 455 } 456 457 ctx.textBaseline = 'bottom'; 458 ctx.font = '10px Helvetica'; 459 460 for (const note of this.trace.notes.notes.values()) { 461 const timestamp = getStartTimestamp(note); 462 // TODO(hjd): We should still render area selection marks in viewport is 463 // *within* the area (e.g. both lhs and rhs are out of bounds). 464 if ( 465 (note.noteType === 'DEFAULT' && 466 !visibleWindow.contains(note.timestamp)) || 467 (note.noteType === 'SPAN' && 468 !visibleWindow.overlaps(note.start, note.end)) 469 ) { 470 continue; 471 } 472 const currentIsHovered = 473 this.hoveredX !== null && this.hitTestNote(this.hoveredX, note); 474 if (currentIsHovered) aNoteIsHovered = true; 475 476 const selection = this.trace.selection.selection; 477 const isSelected = selection.kind === 'note' && selection.id === note.id; 478 const x = timescale.timeToPx(timestamp); 479 const left = Math.floor(x); 480 481 // Draw flag or marker. 482 if (note.noteType === 'SPAN') { 483 this.drawAreaMarker( 484 ctx, 485 left, 486 Math.floor(timescale.timeToPx(note.end)), 487 note.color, 488 isSelected, 489 ); 490 } else { 491 this.drawFlag(ctx, left, size.height, note.color, isSelected); 492 } 493 494 if (note.text) { 495 const summary = toSummary(note.text); 496 const measured = ctx.measureText(summary); 497 // Add a white semi-transparent background for the text. 498 ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; 499 ctx.fillRect( 500 left + FLAG_WIDTH + 2, 501 size.height + 2, 502 measured.width + 2, 503 -12, 504 ); 505 ctx.fillStyle = '#3c4b5d'; 506 ctx.fillText(summary, left + FLAG_WIDTH + 3, size.height + 1); 507 } 508 } 509 510 // A real note is hovered so we don't need to see the preview line. 511 // TODO(hjd): Change cursor to pointer here. 512 if (aNoteIsHovered) { 513 this.trace.timeline.hoveredNoteTimestamp = undefined; 514 } 515 516 // View preview note flag when hovering on notes panel. 517 if (!aNoteIsHovered && this.hoveredX !== null) { 518 const timestamp = timescale.pxToHpTime(this.hoveredX).toTime(); 519 if (visibleWindow.contains(timestamp)) { 520 this.trace.timeline.hoveredNoteTimestamp = timestamp; 521 const x = timescale.timeToPx(timestamp); 522 const left = Math.floor(x); 523 this.drawFlag(ctx, left, size.height, '#aaa', /* fill */ true); 524 } 525 } 526 527 ctx.restore(); 528 } 529 530 private drawAreaMarker( 531 ctx: CanvasRenderingContext2D, 532 x: number, 533 xEnd: number, 534 color: string, 535 fill: boolean, 536 ) { 537 ctx.fillStyle = color; 538 ctx.strokeStyle = color; 539 const topOffset = 10; 540 // Don't draw in the track shell section. 541 if (x >= 0) { 542 // Draw left triangle. 543 ctx.beginPath(); 544 ctx.moveTo(x, topOffset); 545 ctx.lineTo(x, topOffset + AREA_TRIANGLE_WIDTH); 546 ctx.lineTo(x + AREA_TRIANGLE_WIDTH, topOffset); 547 ctx.lineTo(x, topOffset); 548 if (fill) ctx.fill(); 549 ctx.stroke(); 550 } 551 // Draw right triangle. 552 ctx.beginPath(); 553 ctx.moveTo(xEnd, topOffset); 554 ctx.lineTo(xEnd, topOffset + AREA_TRIANGLE_WIDTH); 555 ctx.lineTo(xEnd - AREA_TRIANGLE_WIDTH, topOffset); 556 ctx.lineTo(xEnd, topOffset); 557 if (fill) ctx.fill(); 558 ctx.stroke(); 559 560 // Start line after track shell section, join triangles. 561 const startDraw = Math.max(x, 0); 562 ctx.beginPath(); 563 ctx.moveTo(startDraw, topOffset); 564 ctx.lineTo(xEnd, topOffset); 565 ctx.stroke(); 566 } 567 568 private drawFlag( 569 ctx: CanvasRenderingContext2D, 570 x: number, 571 height: number, 572 color: string, 573 fill?: boolean, 574 ) { 575 const prevFont = ctx.font; 576 const prevBaseline = ctx.textBaseline; 577 ctx.textBaseline = 'alphabetic'; 578 // Adjust height for icon font. 579 ctx.font = '24px Material Symbols Sharp'; 580 ctx.fillStyle = color; 581 ctx.strokeStyle = color; 582 // The ligatures have padding included that means the icon is not drawn 583 // exactly at the x value. This adjusts for that. 584 const iconPadding = 6; 585 if (fill) { 586 ctx.fillText(FLAG, x - iconPadding, height + 2); 587 } else { 588 ctx.strokeText(FLAG, x - iconPadding, height + 2.5); 589 } 590 ctx.font = prevFont; 591 ctx.textBaseline = prevBaseline; 592 } 593 594 private onClick(x: number) { 595 if (!this.timescale) { 596 return; 597 } 598 599 // Select the hovered note, or create a new single note & select it 600 if (x < 0) return; 601 for (const note of this.trace.notes.notes.values()) { 602 if (this.hoveredX !== null && this.hitTestNote(this.hoveredX, note)) { 603 this.trace.selection.selectNote({id: note.id}); 604 return; 605 } 606 } 607 const timestamp = this.timescale.pxToHpTime(x).toTime(); 608 const color = randomColor(); 609 const noteId = this.trace.notes.addNote({timestamp, color}); 610 this.trace.selection.selectNote({id: noteId}); 611 } 612 613 private hitTestNote(x: number, note: SpanNote | Note): boolean { 614 if (!this.timescale) { 615 return false; 616 } 617 618 const timescale = this.timescale; 619 const noteX = timescale.timeToPx(getStartTimestamp(note)); 620 if (note.noteType === 'SPAN') { 621 return ( 622 (noteX <= x && x < noteX + AREA_TRIANGLE_WIDTH) || 623 (timescale.timeToPx(note.end) > x && 624 x > timescale.timeToPx(note.end) - AREA_TRIANGLE_WIDTH) 625 ); 626 } else { 627 const width = FLAG_WIDTH; 628 return noteX <= x && x < noteX + width; 629 } 630 } 631} 632