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 * as m from 'mithril'; 16 17import {Actions} from '../common/actions'; 18import {randomColor} from '../common/colorizer'; 19import {AreaNote, Note} from '../common/state'; 20import {timeToString} from '../common/time'; 21 22import {TRACK_SHELL_WIDTH} from './css_constants'; 23import {PerfettoMouseEvent} from './events'; 24import {globals} from './globals'; 25import {gridlines} from './gridline_helper'; 26import {Panel, PanelSize} from './panel'; 27 28const FLAG_WIDTH = 16; 29const AREA_TRIANGLE_WIDTH = 10; 30const MOVIE_WIDTH = 16; 31const FLAG = `\uE153`; 32const MOVIE = '\uE8DA'; 33 34function toSummary(s: string) { 35 const newlineIndex = s.indexOf('\n') > 0 ? s.indexOf('\n') : s.length; 36 return s.slice(0, Math.min(newlineIndex, s.length, 16)); 37} 38 39function getStartTimestamp(note: Note|AreaNote) { 40 if (note.noteType === 'AREA') { 41 return globals.state.areas[note.areaId].startSec; 42 } else { 43 return note.timestamp; 44 } 45} 46 47export class NotesPanel extends Panel { 48 hoveredX: null|number = null; 49 50 oncreate({dom}: m.CVnodeDOM) { 51 dom.addEventListener('mousemove', (e: Event) => { 52 this.hoveredX = (e as PerfettoMouseEvent).layerX - TRACK_SHELL_WIDTH; 53 if (globals.state.scrubbingEnabled) { 54 const timescale = globals.frontendLocalState.timeScale; 55 const timestamp = timescale.pxToTime(this.hoveredX); 56 globals.frontendLocalState.setVidTimestamp(timestamp); 57 } 58 globals.rafScheduler.scheduleRedraw(); 59 }, {passive: true}); 60 dom.addEventListener('mouseenter', (e: Event) => { 61 this.hoveredX = (e as PerfettoMouseEvent).layerX - TRACK_SHELL_WIDTH; 62 globals.rafScheduler.scheduleRedraw(); 63 }); 64 dom.addEventListener('mouseout', () => { 65 this.hoveredX = null; 66 globals.frontendLocalState.setHoveredNoteTimestamp(-1); 67 globals.rafScheduler.scheduleRedraw(); 68 }, {passive: true}); 69 } 70 71 view() { 72 return m('.notes-panel', { 73 onclick: (e: PerfettoMouseEvent) => { 74 const isMovie = globals.state.flagPauseEnabled; 75 this.onClick(e.layerX - TRACK_SHELL_WIDTH, e.layerY, isMovie); 76 e.stopPropagation(); 77 }, 78 }); 79 } 80 81 renderCanvas(ctx: CanvasRenderingContext2D, size: PanelSize) { 82 const timeScale = globals.frontendLocalState.timeScale; 83 const range = globals.frontendLocalState.visibleWindowTime; 84 let aNoteIsHovered = false; 85 86 ctx.fillStyle = '#999'; 87 ctx.fillRect(TRACK_SHELL_WIDTH - 2, 0, 2, size.height); 88 for (const xAndTime of gridlines(size.width, range, timeScale)) { 89 ctx.fillRect(xAndTime[0], 0, 1, size.height); 90 } 91 92 ctx.textBaseline = 'bottom'; 93 ctx.font = '10px Helvetica'; 94 95 for (const note of Object.values(globals.state.notes)) { 96 const timestamp = getStartTimestamp(note); 97 if ((note.noteType !== 'AREA' && !timeScale.timeInBounds(timestamp)) || 98 (note.noteType === 'AREA' && 99 !timeScale.timeInBounds(globals.state.areas[note.areaId].endSec) && 100 !timeScale.timeInBounds( 101 globals.state.areas[note.areaId].startSec))) { 102 continue; 103 } 104 const currentIsHovered = 105 this.hoveredX && this.mouseOverNote(this.hoveredX, note); 106 if (currentIsHovered) aNoteIsHovered = true; 107 108 const selection = globals.state.currentSelection; 109 const isSelected = selection !== null && 110 ((selection.kind === 'NOTE' && selection.id === note.id) || 111 (selection.kind === 'AREA' && selection.noteId === note.id)); 112 const x = timeScale.timeToPx(timestamp); 113 const left = Math.floor(x + TRACK_SHELL_WIDTH); 114 115 // Draw flag or marker. 116 if (note.noteType === 'AREA') { 117 const area = globals.state.areas[note.areaId]; 118 this.drawAreaMarker( 119 ctx, 120 left, 121 Math.floor(timeScale.timeToPx(area.endSec) + TRACK_SHELL_WIDTH), 122 note.color, 123 isSelected); 124 } else { 125 this.drawFlag( 126 ctx, left, size.height, note.color, note.noteType, isSelected); 127 } 128 129 if (note.text) { 130 const summary = toSummary(note.text); 131 const measured = ctx.measureText(summary); 132 // Add a white semi-transparent background for the text. 133 ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; 134 ctx.fillRect( 135 left + FLAG_WIDTH + 2, size.height + 2, measured.width + 2, -12); 136 ctx.fillStyle = '#3c4b5d'; 137 ctx.fillText(summary, left + FLAG_WIDTH + 3, size.height + 1); 138 } 139 } 140 141 // A real note is hovered so we don't need to see the preview line. 142 // TODO(taylori): Change cursor to pointer here. 143 if (aNoteIsHovered) globals.frontendLocalState.setHoveredNoteTimestamp(-1); 144 145 // View preview note flag when hovering on notes panel. 146 if (!aNoteIsHovered && this.hoveredX !== null) { 147 const timestamp = timeScale.pxToTime(this.hoveredX); 148 if (timeScale.timeInBounds(timestamp)) { 149 globals.frontendLocalState.setHoveredNoteTimestamp(timestamp); 150 const x = timeScale.timeToPx(timestamp); 151 const left = Math.floor(x + TRACK_SHELL_WIDTH); 152 this.drawFlag( 153 ctx, left, size.height, '#aaa', 'DEFAULT', /* fill */ true); 154 } 155 } 156 } 157 158 private drawAreaMarker( 159 ctx: CanvasRenderingContext2D, x: number, xEnd: number, color: string, 160 fill: boolean) { 161 ctx.fillStyle = color; 162 ctx.strokeStyle = color; 163 const topOffset = 10; 164 // Don't draw in the track shell section. 165 if (x >= globals.frontendLocalState.timeScale.startPx + TRACK_SHELL_WIDTH) { 166 // Draw left triangle. 167 ctx.beginPath(); 168 ctx.moveTo(x, topOffset); 169 ctx.lineTo(x, topOffset + AREA_TRIANGLE_WIDTH); 170 ctx.lineTo(x + AREA_TRIANGLE_WIDTH, topOffset); 171 ctx.lineTo(x, topOffset); 172 if (fill) ctx.fill(); 173 ctx.stroke(); 174 } 175 // Draw right triangle. 176 ctx.beginPath(); 177 ctx.moveTo(xEnd, topOffset); 178 ctx.lineTo(xEnd, topOffset + AREA_TRIANGLE_WIDTH); 179 ctx.lineTo(xEnd - AREA_TRIANGLE_WIDTH, topOffset); 180 ctx.lineTo(xEnd, topOffset); 181 if (fill) ctx.fill(); 182 ctx.stroke(); 183 184 // Start line after track shell section, join triangles. 185 const startDraw = 186 Math.max( 187 x, 188 globals.frontendLocalState.timeScale.startPx + TRACK_SHELL_WIDTH) - 189 1; 190 ctx.fillRect(startDraw, topOffset - 1, xEnd - startDraw + 1, 1); 191 } 192 193 private drawFlag( 194 ctx: CanvasRenderingContext2D, x: number, height: number, color: string, 195 noteType: 'DEFAULT'|'AREA'|'MOVIE', fill?: boolean) { 196 const prevFont = ctx.font; 197 const prevBaseline = ctx.textBaseline; 198 ctx.textBaseline = 'alphabetic'; 199 // Adjust height for icon font. 200 ctx.font = '24px Material Icons'; 201 ctx.fillStyle = color; 202 ctx.strokeStyle = color; 203 // The ligatures have padding included that means the icon is not drawn 204 // exactly at the x value. This adjusts for that. 205 const iconPadding = 6; 206 if (fill) { 207 ctx.fillText( 208 noteType === 'MOVIE' ? MOVIE : FLAG, x - iconPadding, height + 2); 209 } else { 210 ctx.strokeText( 211 noteType === 'MOVIE' ? MOVIE : FLAG, x - iconPadding, height + 2.5); 212 } 213 ctx.font = prevFont; 214 ctx.textBaseline = prevBaseline; 215 } 216 217 218 private onClick(x: number, _: number, isMovie: boolean) { 219 if (x < 0) return; 220 const timeScale = globals.frontendLocalState.timeScale; 221 const timestamp = timeScale.pxToTime(x); 222 for (const note of Object.values(globals.state.notes)) { 223 if (this.hoveredX && this.mouseOverNote(this.hoveredX, note)) { 224 if (note.noteType === 'MOVIE') { 225 globals.frontendLocalState.setVidTimestamp(note.timestamp); 226 } 227 if (note.noteType === 'AREA') { 228 globals.makeSelection( 229 Actions.reSelectArea({areaId: note.areaId, noteId: note.id})); 230 } else { 231 globals.makeSelection(Actions.selectNote({id: note.id})); 232 } 233 return; 234 } 235 } 236 if (isMovie) { 237 globals.frontendLocalState.setVidTimestamp(timestamp); 238 } 239 const color = randomColor(); 240 globals.makeSelection(Actions.addNote({timestamp, color, isMovie})); 241 } 242 243 private mouseOverNote(x: number, note: AreaNote|Note): boolean { 244 const timeScale = globals.frontendLocalState.timeScale; 245 const noteX = timeScale.timeToPx(getStartTimestamp(note)); 246 if (note.noteType === 'AREA') { 247 const noteArea = globals.state.areas[note.areaId]; 248 return (noteX <= x && x < noteX + AREA_TRIANGLE_WIDTH) || 249 (timeScale.timeToPx(noteArea.endSec) > x && 250 x > timeScale.timeToPx(noteArea.endSec) - AREA_TRIANGLE_WIDTH); 251 } else { 252 const width = (note.noteType === 'MOVIE') ? MOVIE_WIDTH : FLAG_WIDTH; 253 return noteX <= x && x < noteX + width; 254 } 255 } 256} 257 258interface NotesEditorPanelAttrs { 259 id: string; 260} 261 262export class NotesEditorPanel extends Panel<NotesEditorPanelAttrs> { 263 view({attrs}: m.CVnode<NotesEditorPanelAttrs>) { 264 const note = globals.state.notes[attrs.id]; 265 const startTime = 266 getStartTimestamp(note) - globals.state.traceTime.startSec; 267 return m( 268 '.notes-editor-panel', 269 m('.notes-editor-panel-heading-bar', 270 m('.notes-editor-panel-heading', 271 `Annotation at ${timeToString(startTime)}`), 272 m('input[type=text]', { 273 onkeydown: (e: Event) => { 274 e.stopImmediatePropagation(); 275 }, 276 value: note.text, 277 onchange: (e: InputEvent) => { 278 const newText = (e.target as HTMLInputElement).value; 279 globals.dispatch(Actions.changeNoteText({ 280 id: attrs.id, 281 newText, 282 })); 283 }, 284 }), 285 m('span.color-change', `Change color: `, m('input[type=color]', { 286 value: note.color, 287 onchange: (e: Event) => { 288 const newColor = (e.target as HTMLInputElement).value; 289 globals.dispatch(Actions.changeNoteColor({ 290 id: attrs.id, 291 newColor, 292 })); 293 }, 294 })), 295 m('button', 296 { 297 onclick: () => { 298 globals.dispatch(Actions.removeNote({id: attrs.id})); 299 globals.frontendLocalState.currentTab = undefined; 300 globals.rafScheduler.scheduleFullRedraw(); 301 } 302 }, 303 'Remove')), 304 ); 305 } 306 307 renderCanvas(_ctx: CanvasRenderingContext2D, _size: PanelSize) {} 308} 309