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