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 {currentTargetOffset} from '../base/dom_utils'; 17import {Icons} from '../base/semantic_icons'; 18import {randomColor} from '../components/colorizer'; 19import {SpanNote, Note} from '../public/note'; 20import {raf} from '../core/raf_scheduler'; 21import {Button, ButtonBar} from '../widgets/button'; 22import {TRACK_SHELL_WIDTH} from './css_constants'; 23import {getMaxMajorTicks, generateTicks, TickType} from './gridline_helper'; 24import {Size2D} from '../base/geom'; 25import {Panel} from './panel_container'; 26import {Timestamp} from '../components/widgets/timestamp'; 27import {assertUnreachable} from '../base/logging'; 28import {DetailsPanel} from '../public/details_panel'; 29import {TimeScale} from '../base/time_scale'; 30import {canvasClip} from '../base/canvas_utils'; 31import {Selection} from '../public/selection'; 32import {TraceImpl} from '../core/trace_impl'; 33 34const FLAG_WIDTH = 16; 35const AREA_TRIANGLE_WIDTH = 10; 36const FLAG = `\uE153`; 37 38function toSummary(s: string) { 39 const newlineIndex = s.indexOf('\n') > 0 ? s.indexOf('\n') : s.length; 40 return s.slice(0, Math.min(newlineIndex, s.length, 16)); 41} 42 43function getStartTimestamp(note: Note | SpanNote) { 44 const noteType = note.noteType; 45 switch (noteType) { 46 case 'SPAN': 47 return note.start; 48 case 'DEFAULT': 49 return note.timestamp; 50 default: 51 assertUnreachable(noteType); 52 } 53} 54 55export class NotesPanel implements Panel { 56 readonly kind = 'panel'; 57 readonly selectable = false; 58 private readonly trace: TraceImpl; 59 private timescale?: TimeScale; // The timescale from the last render() 60 private hoveredX: null | number = null; 61 private mouseDragging = false; 62 63 constructor(trace: TraceImpl) { 64 this.trace = trace; 65 } 66 67 render(): m.Children { 68 const allCollapsed = this.trace.workspace.flatTracks.every( 69 (n) => n.collapsed, 70 ); 71 72 return m( 73 '.notes-panel', 74 { 75 onmousedown: () => { 76 // If the user clicks & drags, very likely they just want to measure 77 // the time horizontally, not set a flag. This debouncing is done to 78 // avoid setting accidental flags like measuring the time on the brush 79 // timeline. 80 this.mouseDragging = false; 81 }, 82 onclick: (e: MouseEvent) => { 83 if (!this.mouseDragging) { 84 const x = currentTargetOffset(e).x - TRACK_SHELL_WIDTH; 85 this.onClick(x); 86 e.stopPropagation(); 87 } 88 }, 89 onmousemove: (e: MouseEvent) => { 90 this.mouseDragging = true; 91 this.hoveredX = currentTargetOffset(e).x - TRACK_SHELL_WIDTH; 92 raf.scheduleCanvasRedraw(); 93 }, 94 onmouseenter: (e: MouseEvent) => { 95 this.hoveredX = currentTargetOffset(e).x - TRACK_SHELL_WIDTH; 96 raf.scheduleCanvasRedraw(); 97 }, 98 onmouseout: () => { 99 this.hoveredX = null; 100 this.trace.timeline.hoveredNoteTimestamp = undefined; 101 }, 102 }, 103 m( 104 ButtonBar, 105 {className: 'pf-toolbar'}, 106 m(Button, { 107 onclick: (e: Event) => { 108 e.preventDefault(); 109 if (allCollapsed) { 110 this.trace.commands.runCommand( 111 'perfetto.CoreCommands#ExpandAllGroups', 112 ); 113 } else { 114 this.trace.commands.runCommand( 115 'perfetto.CoreCommands#CollapseAllGroups', 116 ); 117 } 118 }, 119 title: allCollapsed ? 'Expand all' : 'Collapse all', 120 icon: allCollapsed ? 'unfold_more' : 'unfold_less', 121 compact: true, 122 }), 123 m(Button, { 124 onclick: (e: Event) => { 125 e.preventDefault(); 126 this.trace.workspace.pinnedTracks.forEach((t) => 127 this.trace.workspace.unpinTrack(t), 128 ); 129 raf.scheduleFullRedraw(); 130 }, 131 title: 'Clear all pinned tracks', 132 icon: 'clear_all', 133 compact: true, 134 }), 135 // TODO(stevegolton): Re-introduce this when we fix track filtering 136 // m(TextInput, { 137 // placeholder: 'Filter tracks...', 138 // title: 139 // 'Track filter - enter one or more comma-separated search terms', 140 // value: this.trace.state.trackFilterTerm, 141 // oninput: (e: Event) => { 142 // const filterTerm = (e.target as HTMLInputElement).value; 143 // this.trace.dispatch(Actions.setTrackFilterTerm({filterTerm})); 144 // }, 145 // }), 146 // m(Button, { 147 // type: 'reset', 148 // icon: 'backspace', 149 // onclick: () => { 150 // this.trace.dispatch( 151 // Actions.setTrackFilterTerm({filterTerm: undefined}), 152 // ); 153 // }, 154 // title: 'Clear track filter', 155 // }), 156 ), 157 ); 158 } 159 160 renderCanvas(ctx: CanvasRenderingContext2D, size: Size2D) { 161 ctx.fillStyle = '#999'; 162 ctx.fillRect(TRACK_SHELL_WIDTH - 2, 0, 2, size.height); 163 164 const trackSize = {...size, width: size.width - TRACK_SHELL_WIDTH}; 165 166 ctx.save(); 167 ctx.translate(TRACK_SHELL_WIDTH, 0); 168 canvasClip(ctx, 0, 0, trackSize.width, trackSize.height); 169 this.renderPanel(ctx, trackSize); 170 ctx.restore(); 171 } 172 173 private renderPanel(ctx: CanvasRenderingContext2D, size: Size2D): void { 174 let aNoteIsHovered = false; 175 176 const visibleWindow = this.trace.timeline.visibleWindow; 177 const timescale = new TimeScale(visibleWindow, { 178 left: 0, 179 right: size.width, 180 }); 181 const timespan = visibleWindow.toTimeSpan(); 182 183 this.timescale = timescale; 184 185 if (size.width > 0 && timespan.duration > 0n) { 186 const maxMajorTicks = getMaxMajorTicks(size.width); 187 const offset = this.trace.timeline.timestampOffset(); 188 const tickGen = generateTicks(timespan, maxMajorTicks, offset); 189 for (const {type, time} of tickGen) { 190 const px = Math.floor(timescale.timeToPx(time)); 191 if (type === TickType.MAJOR) { 192 ctx.fillRect(px, 0, 1, size.height); 193 } 194 } 195 } 196 197 ctx.textBaseline = 'bottom'; 198 ctx.font = '10px Helvetica'; 199 200 for (const note of this.trace.notes.notes.values()) { 201 const timestamp = getStartTimestamp(note); 202 // TODO(hjd): We should still render area selection marks in viewport is 203 // *within* the area (e.g. both lhs and rhs are out of bounds). 204 if ( 205 (note.noteType === 'DEFAULT' && 206 !visibleWindow.contains(note.timestamp)) || 207 (note.noteType === 'SPAN' && 208 !visibleWindow.overlaps(note.start, note.end)) 209 ) { 210 continue; 211 } 212 const currentIsHovered = 213 this.hoveredX !== null && this.hitTestNote(this.hoveredX, note); 214 if (currentIsHovered) aNoteIsHovered = true; 215 216 const selection = this.trace.selection.selection; 217 const isSelected = selection.kind === 'note' && selection.id === note.id; 218 const x = timescale.timeToPx(timestamp); 219 const left = Math.floor(x); 220 221 // Draw flag or marker. 222 if (note.noteType === 'SPAN') { 223 this.drawAreaMarker( 224 ctx, 225 left, 226 Math.floor(timescale.timeToPx(note.end)), 227 note.color, 228 isSelected, 229 ); 230 } else { 231 this.drawFlag(ctx, left, size.height, note.color, isSelected); 232 } 233 234 if (note.text) { 235 const summary = toSummary(note.text); 236 const measured = ctx.measureText(summary); 237 // Add a white semi-transparent background for the text. 238 ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; 239 ctx.fillRect( 240 left + FLAG_WIDTH + 2, 241 size.height + 2, 242 measured.width + 2, 243 -12, 244 ); 245 ctx.fillStyle = '#3c4b5d'; 246 ctx.fillText(summary, left + FLAG_WIDTH + 3, size.height + 1); 247 } 248 } 249 250 // A real note is hovered so we don't need to see the preview line. 251 // TODO(hjd): Change cursor to pointer here. 252 if (aNoteIsHovered) { 253 this.trace.timeline.hoveredNoteTimestamp = undefined; 254 } 255 256 // View preview note flag when hovering on notes panel. 257 if (!aNoteIsHovered && this.hoveredX !== null) { 258 const timestamp = timescale.pxToHpTime(this.hoveredX).toTime(); 259 if (visibleWindow.contains(timestamp)) { 260 this.trace.timeline.hoveredNoteTimestamp = timestamp; 261 const x = timescale.timeToPx(timestamp); 262 const left = Math.floor(x); 263 this.drawFlag(ctx, left, size.height, '#aaa', /* fill */ true); 264 } 265 } 266 267 ctx.restore(); 268 } 269 270 private drawAreaMarker( 271 ctx: CanvasRenderingContext2D, 272 x: number, 273 xEnd: number, 274 color: string, 275 fill: boolean, 276 ) { 277 ctx.fillStyle = color; 278 ctx.strokeStyle = color; 279 const topOffset = 10; 280 // Don't draw in the track shell section. 281 if (x >= 0) { 282 // Draw left triangle. 283 ctx.beginPath(); 284 ctx.moveTo(x, topOffset); 285 ctx.lineTo(x, topOffset + AREA_TRIANGLE_WIDTH); 286 ctx.lineTo(x + AREA_TRIANGLE_WIDTH, topOffset); 287 ctx.lineTo(x, topOffset); 288 if (fill) ctx.fill(); 289 ctx.stroke(); 290 } 291 // Draw right triangle. 292 ctx.beginPath(); 293 ctx.moveTo(xEnd, topOffset); 294 ctx.lineTo(xEnd, topOffset + AREA_TRIANGLE_WIDTH); 295 ctx.lineTo(xEnd - AREA_TRIANGLE_WIDTH, topOffset); 296 ctx.lineTo(xEnd, topOffset); 297 if (fill) ctx.fill(); 298 ctx.stroke(); 299 300 // Start line after track shell section, join triangles. 301 const startDraw = Math.max(x, 0); 302 ctx.beginPath(); 303 ctx.moveTo(startDraw, topOffset); 304 ctx.lineTo(xEnd, topOffset); 305 ctx.stroke(); 306 } 307 308 private drawFlag( 309 ctx: CanvasRenderingContext2D, 310 x: number, 311 height: number, 312 color: string, 313 fill?: boolean, 314 ) { 315 const prevFont = ctx.font; 316 const prevBaseline = ctx.textBaseline; 317 ctx.textBaseline = 'alphabetic'; 318 // Adjust height for icon font. 319 ctx.font = '24px Material Symbols Sharp'; 320 ctx.fillStyle = color; 321 ctx.strokeStyle = color; 322 // The ligatures have padding included that means the icon is not drawn 323 // exactly at the x value. This adjusts for that. 324 const iconPadding = 6; 325 if (fill) { 326 ctx.fillText(FLAG, x - iconPadding, height + 2); 327 } else { 328 ctx.strokeText(FLAG, x - iconPadding, height + 2.5); 329 } 330 ctx.font = prevFont; 331 ctx.textBaseline = prevBaseline; 332 } 333 334 private onClick(x: number) { 335 if (!this.timescale) { 336 return; 337 } 338 339 // Select the hovered note, or create a new single note & select it 340 if (x < 0) return; 341 for (const note of this.trace.notes.notes.values()) { 342 if (this.hoveredX !== null && this.hitTestNote(this.hoveredX, note)) { 343 this.trace.selection.selectNote({id: note.id}); 344 return; 345 } 346 } 347 const timestamp = this.timescale.pxToHpTime(x).toTime(); 348 const color = randomColor(); 349 const noteId = this.trace.notes.addNote({timestamp, color}); 350 this.trace.selection.selectNote({id: noteId}); 351 } 352 353 private hitTestNote(x: number, note: SpanNote | Note): boolean { 354 if (!this.timescale) { 355 return false; 356 } 357 358 const timescale = this.timescale; 359 const noteX = timescale.timeToPx(getStartTimestamp(note)); 360 if (note.noteType === 'SPAN') { 361 return ( 362 (noteX <= x && x < noteX + AREA_TRIANGLE_WIDTH) || 363 (timescale.timeToPx(note.end) > x && 364 x > timescale.timeToPx(note.end) - AREA_TRIANGLE_WIDTH) 365 ); 366 } else { 367 const width = FLAG_WIDTH; 368 return noteX <= x && x < noteX + width; 369 } 370 } 371} 372 373export class NotesEditorTab implements DetailsPanel { 374 constructor(private trace: TraceImpl) {} 375 376 render(selection: Selection) { 377 if (selection.kind !== 'note') { 378 return undefined; 379 } 380 381 const id = selection.id; 382 383 const note = this.trace.notes.getNote(id); 384 if (note === undefined) { 385 return m('.', `No Note with id ${id}`); 386 } 387 const startTime = getStartTimestamp(note); 388 return m( 389 '.notes-editor-panel', 390 { 391 key: id, // Every note shoul get its own brand new DOM. 392 }, 393 m( 394 '.notes-editor-panel-heading-bar', 395 m( 396 '.notes-editor-panel-heading', 397 `Annotation at `, 398 m(Timestamp, {ts: startTime}), 399 ), 400 m('input[type=text]', { 401 oncreate: (v: m.VnodeDOM) => { 402 // NOTE: due to bad design decisions elsewhere this component is 403 // rendered every time the mouse moves on the canvas. We cannot set 404 // `value: note.text` as an input as that will clobber the input 405 // value as we move the mouse. 406 const inputElement = v.dom as HTMLInputElement; 407 inputElement.value = note.text; 408 inputElement.focus(); 409 }, 410 onchange: (e: InputEvent) => { 411 const newText = (e.target as HTMLInputElement).value; 412 this.trace.notes.changeNote(id, {text: newText}); 413 }, 414 }), 415 m( 416 'span.color-change', 417 `Change color: `, 418 m('input[type=color]', { 419 value: note.color, 420 onchange: (e: Event) => { 421 const newColor = (e.target as HTMLInputElement).value; 422 this.trace.notes.changeNote(id, {color: newColor}); 423 }, 424 }), 425 ), 426 m(Button, { 427 label: 'Remove', 428 icon: Icons.Delete, 429 onclick: () => this.trace.notes.removeNote(id), 430 }), 431 ), 432 ); 433 } 434} 435