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'; 16 17import {currentTargetOffset} from '../base/dom_utils'; 18import {Icons} from '../base/semantic_icons'; 19import {Time} from '../base/time'; 20import {Actions} from '../common/actions'; 21import {randomColor} from '../core/colorizer'; 22import {SpanNote, Note, Selection} from '../common/state'; 23import {raf} from '../core/raf_scheduler'; 24import {Button, ButtonBar} from '../widgets/button'; 25 26import {TRACK_SHELL_WIDTH} from './css_constants'; 27import {globals} from './globals'; 28import { 29 getMaxMajorTicks, 30 TickGenerator, 31 TickType, 32 timeScaleForVisibleWindow, 33} from './gridline_helper'; 34import {PanelSize} from './panel'; 35import {Panel} from './panel_container'; 36import {isTraceLoaded} from './sidebar'; 37import {Timestamp} from './widgets/timestamp'; 38import {uuidv4} from '../base/uuid'; 39import {assertUnreachable} from '../base/logging'; 40import {DetailsPanel} from '../public'; 41 42const FLAG_WIDTH = 16; 43const AREA_TRIANGLE_WIDTH = 10; 44const FLAG = `\uE153`; 45 46function toSummary(s: string) { 47 const newlineIndex = s.indexOf('\n') > 0 ? s.indexOf('\n') : s.length; 48 return s.slice(0, Math.min(newlineIndex, s.length, 16)); 49} 50 51function getStartTimestamp(note: Note | SpanNote) { 52 const noteType = note.noteType; 53 switch (noteType) { 54 case 'SPAN': 55 return note.start; 56 case 'DEFAULT': 57 return note.timestamp; 58 default: 59 assertUnreachable(noteType); 60 } 61} 62 63export class NotesPanel implements Panel { 64 readonly kind = 'panel'; 65 readonly selectable = false; 66 67 hoveredX: null | number = null; 68 69 render(): m.Children { 70 const allCollapsed = Object.values(globals.state.trackGroups).every( 71 (group) => group.collapsed, 72 ); 73 74 return m( 75 '.notes-panel', 76 { 77 onclick: (e: MouseEvent) => { 78 const {x, y} = currentTargetOffset(e); 79 this.onClick(x - TRACK_SHELL_WIDTH, y); 80 e.stopPropagation(); 81 }, 82 onmousemove: (e: MouseEvent) => { 83 this.hoveredX = currentTargetOffset(e).x - TRACK_SHELL_WIDTH; 84 raf.scheduleRedraw(); 85 }, 86 onmouseenter: (e: MouseEvent) => { 87 this.hoveredX = currentTargetOffset(e).x - TRACK_SHELL_WIDTH; 88 raf.scheduleRedraw(); 89 }, 90 onmouseout: () => { 91 this.hoveredX = null; 92 globals.dispatch(Actions.setHoveredNoteTimestamp({ts: Time.INVALID})); 93 }, 94 }, 95 isTraceLoaded() && 96 m( 97 ButtonBar, 98 {className: 'pf-toolbar'}, 99 m(Button, { 100 onclick: (e: Event) => { 101 e.preventDefault(); 102 if (allCollapsed) { 103 globals.commandManager.runCommand( 104 'dev.perfetto.CoreCommands#ExpandAllGroups', 105 ); 106 } else { 107 globals.commandManager.runCommand( 108 'dev.perfetto.CoreCommands#CollapseAllGroups', 109 ); 110 } 111 }, 112 title: allCollapsed ? 'Expand all' : 'Collapse all', 113 icon: allCollapsed ? 'unfold_more' : 'unfold_less', 114 compact: true, 115 }), 116 m(Button, { 117 onclick: (e: Event) => { 118 e.preventDefault(); 119 globals.dispatch(Actions.clearAllPinnedTracks({})); 120 }, 121 title: 'Clear all pinned tracks', 122 icon: 'clear_all', 123 compact: true, 124 }), 125 ), 126 ); 127 } 128 129 renderCanvas(ctx: CanvasRenderingContext2D, size: PanelSize) { 130 let aNoteIsHovered = false; 131 132 ctx.fillStyle = '#999'; 133 ctx.fillRect(TRACK_SHELL_WIDTH - 2, 0, 2, size.height); 134 135 ctx.save(); 136 ctx.beginPath(); 137 ctx.rect(TRACK_SHELL_WIDTH, 0, size.width - TRACK_SHELL_WIDTH, size.height); 138 ctx.clip(); 139 140 const span = globals.timeline.visibleTimeSpan; 141 const {visibleTimeScale} = globals.timeline; 142 if (size.width > TRACK_SHELL_WIDTH && span.duration > 0n) { 143 const maxMajorTicks = getMaxMajorTicks(size.width - TRACK_SHELL_WIDTH); 144 const map = timeScaleForVisibleWindow(TRACK_SHELL_WIDTH, size.width); 145 const offset = globals.timestampOffset(); 146 const tickGen = new TickGenerator(span, maxMajorTicks, offset); 147 for (const {type, time} of tickGen) { 148 const px = Math.floor(map.timeToPx(time)); 149 if (type === TickType.MAJOR) { 150 ctx.fillRect(px, 0, 1, size.height); 151 } 152 } 153 } 154 155 ctx.textBaseline = 'bottom'; 156 ctx.font = '10px Helvetica'; 157 158 for (const note of Object.values(globals.state.notes)) { 159 const timestamp = getStartTimestamp(note); 160 // TODO(hjd): We should still render area selection marks in viewport is 161 // *within* the area (e.g. both lhs and rhs are out of bounds). 162 if ( 163 (note.noteType === 'DEFAULT' && !span.contains(note.timestamp)) || 164 (note.noteType === 'SPAN' && !span.intersects(note.start, note.end)) 165 ) { 166 continue; 167 } 168 const currentIsHovered = 169 this.hoveredX !== null && this.hitTestNote(this.hoveredX, note); 170 if (currentIsHovered) aNoteIsHovered = true; 171 172 const selection = globals.state.selection; 173 const isSelected = selection.kind === 'note' && selection.id === note.id; 174 const x = visibleTimeScale.timeToPx(timestamp); 175 const left = Math.floor(x + TRACK_SHELL_WIDTH); 176 177 // Draw flag or marker. 178 if (note.noteType === 'SPAN') { 179 this.drawAreaMarker( 180 ctx, 181 left, 182 Math.floor(visibleTimeScale.timeToPx(note.end) + TRACK_SHELL_WIDTH), 183 note.color, 184 isSelected, 185 ); 186 } else { 187 this.drawFlag(ctx, left, size.height, note.color, isSelected); 188 } 189 190 if (note.text) { 191 const summary = toSummary(note.text); 192 const measured = ctx.measureText(summary); 193 // Add a white semi-transparent background for the text. 194 ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; 195 ctx.fillRect( 196 left + FLAG_WIDTH + 2, 197 size.height + 2, 198 measured.width + 2, 199 -12, 200 ); 201 ctx.fillStyle = '#3c4b5d'; 202 ctx.fillText(summary, left + FLAG_WIDTH + 3, size.height + 1); 203 } 204 } 205 206 // A real note is hovered so we don't need to see the preview line. 207 // TODO(hjd): Change cursor to pointer here. 208 if (aNoteIsHovered) { 209 globals.dispatch(Actions.setHoveredNoteTimestamp({ts: Time.INVALID})); 210 } 211 212 // View preview note flag when hovering on notes panel. 213 if (!aNoteIsHovered && this.hoveredX !== null) { 214 const timestamp = visibleTimeScale.pxToHpTime(this.hoveredX).toTime(); 215 if (span.contains(timestamp)) { 216 globals.dispatch(Actions.setHoveredNoteTimestamp({ts: timestamp})); 217 const x = visibleTimeScale.timeToPx(timestamp); 218 const left = Math.floor(x + TRACK_SHELL_WIDTH); 219 this.drawFlag(ctx, left, size.height, '#aaa', /* fill */ true); 220 } 221 } 222 223 ctx.restore(); 224 } 225 226 private drawAreaMarker( 227 ctx: CanvasRenderingContext2D, 228 x: number, 229 xEnd: number, 230 color: string, 231 fill: boolean, 232 ) { 233 ctx.fillStyle = color; 234 ctx.strokeStyle = color; 235 const topOffset = 10; 236 // Don't draw in the track shell section. 237 if (x >= TRACK_SHELL_WIDTH) { 238 // Draw left triangle. 239 ctx.beginPath(); 240 ctx.moveTo(x, topOffset); 241 ctx.lineTo(x, topOffset + AREA_TRIANGLE_WIDTH); 242 ctx.lineTo(x + AREA_TRIANGLE_WIDTH, topOffset); 243 ctx.lineTo(x, topOffset); 244 if (fill) ctx.fill(); 245 ctx.stroke(); 246 } 247 // Draw right triangle. 248 ctx.beginPath(); 249 ctx.moveTo(xEnd, topOffset); 250 ctx.lineTo(xEnd, topOffset + AREA_TRIANGLE_WIDTH); 251 ctx.lineTo(xEnd - AREA_TRIANGLE_WIDTH, topOffset); 252 ctx.lineTo(xEnd, topOffset); 253 if (fill) ctx.fill(); 254 ctx.stroke(); 255 256 // Start line after track shell section, join triangles. 257 const startDraw = Math.max(x, TRACK_SHELL_WIDTH); 258 ctx.beginPath(); 259 ctx.moveTo(startDraw, topOffset); 260 ctx.lineTo(xEnd, topOffset); 261 ctx.stroke(); 262 } 263 264 private drawFlag( 265 ctx: CanvasRenderingContext2D, 266 x: number, 267 height: number, 268 color: string, 269 fill?: boolean, 270 ) { 271 const prevFont = ctx.font; 272 const prevBaseline = ctx.textBaseline; 273 ctx.textBaseline = 'alphabetic'; 274 // Adjust height for icon font. 275 ctx.font = '24px Material Symbols Sharp'; 276 ctx.fillStyle = color; 277 ctx.strokeStyle = color; 278 // The ligatures have padding included that means the icon is not drawn 279 // exactly at the x value. This adjusts for that. 280 const iconPadding = 6; 281 if (fill) { 282 ctx.fillText(FLAG, x - iconPadding, height + 2); 283 } else { 284 ctx.strokeText(FLAG, x - iconPadding, height + 2.5); 285 } 286 ctx.font = prevFont; 287 ctx.textBaseline = prevBaseline; 288 } 289 290 private onClick(x: number, _: number) { 291 // Select the hovered note, or create a new single note & select it 292 if (x < 0) return; 293 for (const note of Object.values(globals.state.notes)) { 294 if (this.hoveredX !== null && this.hitTestNote(this.hoveredX, note)) { 295 globals.makeSelection(Actions.selectNote({id: note.id})); 296 return; 297 } 298 } 299 const {visibleTimeScale} = globals.timeline; 300 const timestamp = visibleTimeScale.pxToHpTime(x).toTime(); 301 const id = uuidv4(); 302 const color = randomColor(); 303 globals.dispatchMultiple([ 304 Actions.addNote({id, timestamp, color}), 305 Actions.selectNote({id}), 306 ]); 307 } 308 309 private hitTestNote(x: number, note: SpanNote | Note): boolean { 310 const timeScale = globals.timeline.visibleTimeScale; 311 const noteX = timeScale.timeToPx(getStartTimestamp(note)); 312 if (note.noteType === 'SPAN') { 313 return ( 314 (noteX <= x && x < noteX + AREA_TRIANGLE_WIDTH) || 315 (timeScale.timeToPx(note.end) > x && 316 x > timeScale.timeToPx(note.end) - AREA_TRIANGLE_WIDTH) 317 ); 318 } else { 319 const width = FLAG_WIDTH; 320 return noteX <= x && x < noteX + width; 321 } 322 } 323} 324 325export class NotesEditorTab implements DetailsPanel { 326 render(selection: Selection) { 327 if (selection.kind !== 'note') { 328 return undefined; 329 } 330 331 const id = selection.id; 332 333 const note = globals.state.notes[id]; 334 if (note === undefined) { 335 return m('.', `No Note with id ${id}`); 336 } 337 const startTime = getStartTimestamp(note); 338 return m( 339 '.notes-editor-panel', 340 m( 341 '.notes-editor-panel-heading-bar', 342 m( 343 '.notes-editor-panel-heading', 344 `Annotation at `, 345 m(Timestamp, {ts: startTime}), 346 ), 347 m('input[type=text]', { 348 value: note.text, 349 onchange: (e: InputEvent) => { 350 const newText = (e.target as HTMLInputElement).value; 351 globals.dispatch( 352 Actions.changeNoteText({ 353 id, 354 newText, 355 }), 356 ); 357 }, 358 }), 359 m( 360 'span.color-change', 361 `Change color: `, 362 m('input[type=color]', { 363 value: note.color, 364 onchange: (e: Event) => { 365 const newColor = (e.target as HTMLInputElement).value; 366 globals.dispatch( 367 Actions.changeNoteColor({ 368 id, 369 newColor, 370 }), 371 ); 372 }, 373 }), 374 ), 375 m(Button, { 376 label: 'Remove', 377 icon: Icons.Delete, 378 onclick: () => { 379 globals.dispatch(Actions.removeNote({id})); 380 raf.scheduleFullRedraw(); 381 }, 382 }), 383 ), 384 ); 385 } 386} 387