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 // TODO(hjd): We should still render area selection marks in viewport is 98 // *within* the area (e.g. both lhs and rhs are out of bounds). 99 if ((note.noteType !== 'AREA' && !timeScale.timeInBounds(timestamp)) || 100 (note.noteType === 'AREA' && 101 !timeScale.timeInBounds(globals.state.areas[note.areaId].endSec) && 102 !timeScale.timeInBounds( 103 globals.state.areas[note.areaId].startSec))) { 104 continue; 105 } 106 const currentIsHovered = 107 this.hoveredX && this.mouseOverNote(this.hoveredX, note); 108 if (currentIsHovered) aNoteIsHovered = true; 109 110 const selection = globals.state.currentSelection; 111 const isSelected = selection !== null && 112 ((selection.kind === 'NOTE' && selection.id === note.id) || 113 (selection.kind === 'AREA' && selection.noteId === note.id)); 114 const x = timeScale.timeToPx(timestamp); 115 const left = Math.floor(x + TRACK_SHELL_WIDTH); 116 117 // Draw flag or marker. 118 if (note.noteType === 'AREA') { 119 const area = globals.state.areas[note.areaId]; 120 this.drawAreaMarker( 121 ctx, 122 left, 123 Math.floor(timeScale.timeToPx(area.endSec) + TRACK_SHELL_WIDTH), 124 note.color, 125 isSelected); 126 } else { 127 this.drawFlag( 128 ctx, left, size.height, note.color, note.noteType, isSelected); 129 } 130 131 if (note.text) { 132 const summary = toSummary(note.text); 133 const measured = ctx.measureText(summary); 134 // Add a white semi-transparent background for the text. 135 ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; 136 ctx.fillRect( 137 left + FLAG_WIDTH + 2, size.height + 2, measured.width + 2, -12); 138 ctx.fillStyle = '#3c4b5d'; 139 ctx.fillText(summary, left + FLAG_WIDTH + 3, size.height + 1); 140 } 141 } 142 143 // A real note is hovered so we don't need to see the preview line. 144 // TODO(hjd): Change cursor to pointer here. 145 if (aNoteIsHovered) globals.frontendLocalState.setHoveredNoteTimestamp(-1); 146 147 // View preview note flag when hovering on notes panel. 148 if (!aNoteIsHovered && this.hoveredX !== null) { 149 const timestamp = timeScale.pxToTime(this.hoveredX); 150 if (timeScale.timeInBounds(timestamp)) { 151 globals.frontendLocalState.setHoveredNoteTimestamp(timestamp); 152 const x = timeScale.timeToPx(timestamp); 153 const left = Math.floor(x + TRACK_SHELL_WIDTH); 154 this.drawFlag( 155 ctx, left, size.height, '#aaa', 'DEFAULT', /* fill */ true); 156 } 157 } 158 } 159 160 private drawAreaMarker( 161 ctx: CanvasRenderingContext2D, x: number, xEnd: number, color: string, 162 fill: boolean) { 163 ctx.fillStyle = color; 164 ctx.strokeStyle = color; 165 const topOffset = 10; 166 // Don't draw in the track shell section. 167 if (x >= globals.frontendLocalState.timeScale.startPx + TRACK_SHELL_WIDTH) { 168 // Draw left triangle. 169 ctx.beginPath(); 170 ctx.moveTo(x, topOffset); 171 ctx.lineTo(x, topOffset + AREA_TRIANGLE_WIDTH); 172 ctx.lineTo(x + AREA_TRIANGLE_WIDTH, topOffset); 173 ctx.lineTo(x, topOffset); 174 if (fill) ctx.fill(); 175 ctx.stroke(); 176 } 177 // Draw right triangle. 178 ctx.beginPath(); 179 ctx.moveTo(xEnd, topOffset); 180 ctx.lineTo(xEnd, topOffset + AREA_TRIANGLE_WIDTH); 181 ctx.lineTo(xEnd - AREA_TRIANGLE_WIDTH, topOffset); 182 ctx.lineTo(xEnd, topOffset); 183 if (fill) ctx.fill(); 184 ctx.stroke(); 185 186 // Start line after track shell section, join triangles. 187 const startDraw = Math.max( 188 x, globals.frontendLocalState.timeScale.startPx + TRACK_SHELL_WIDTH); 189 ctx.beginPath(); 190 ctx.moveTo(startDraw, topOffset); 191 ctx.lineTo(xEnd, topOffset); 192 ctx.stroke(); 193 } 194 195 private drawFlag( 196 ctx: CanvasRenderingContext2D, x: number, height: number, color: string, 197 noteType: 'DEFAULT'|'AREA'|'MOVIE', fill?: boolean) { 198 const prevFont = ctx.font; 199 const prevBaseline = ctx.textBaseline; 200 ctx.textBaseline = 'alphabetic'; 201 // Adjust height for icon font. 202 ctx.font = '24px Material Icons'; 203 ctx.fillStyle = color; 204 ctx.strokeStyle = color; 205 // The ligatures have padding included that means the icon is not drawn 206 // exactly at the x value. This adjusts for that. 207 const iconPadding = 6; 208 if (fill) { 209 ctx.fillText( 210 noteType === 'MOVIE' ? MOVIE : FLAG, x - iconPadding, height + 2); 211 } else { 212 ctx.strokeText( 213 noteType === 'MOVIE' ? MOVIE : FLAG, x - iconPadding, height + 2.5); 214 } 215 ctx.font = prevFont; 216 ctx.textBaseline = prevBaseline; 217 } 218 219 220 private onClick(x: number, _: number, isMovie: boolean) { 221 if (x < 0) return; 222 const timeScale = globals.frontendLocalState.timeScale; 223 const timestamp = timeScale.pxToTime(x); 224 for (const note of Object.values(globals.state.notes)) { 225 if (this.hoveredX && this.mouseOverNote(this.hoveredX, note)) { 226 if (note.noteType === 'MOVIE') { 227 globals.frontendLocalState.setVidTimestamp(note.timestamp); 228 } 229 if (note.noteType === 'AREA') { 230 globals.makeSelection( 231 Actions.reSelectArea({areaId: note.areaId, noteId: note.id})); 232 } else { 233 globals.makeSelection(Actions.selectNote({id: note.id})); 234 } 235 return; 236 } 237 } 238 if (isMovie) { 239 globals.frontendLocalState.setVidTimestamp(timestamp); 240 } 241 const color = randomColor(); 242 globals.makeSelection(Actions.addNote({timestamp, color, isMovie})); 243 } 244 245 private mouseOverNote(x: number, note: AreaNote|Note): boolean { 246 const timeScale = globals.frontendLocalState.timeScale; 247 const noteX = timeScale.timeToPx(getStartTimestamp(note)); 248 if (note.noteType === 'AREA') { 249 const noteArea = globals.state.areas[note.areaId]; 250 return (noteX <= x && x < noteX + AREA_TRIANGLE_WIDTH) || 251 (timeScale.timeToPx(noteArea.endSec) > x && 252 x > timeScale.timeToPx(noteArea.endSec) - AREA_TRIANGLE_WIDTH); 253 } else { 254 const width = (note.noteType === 'MOVIE') ? MOVIE_WIDTH : FLAG_WIDTH; 255 return noteX <= x && x < noteX + width; 256 } 257 } 258} 259 260interface NotesEditorPanelAttrs { 261 id: string; 262} 263 264export class NotesEditorPanel extends Panel<NotesEditorPanelAttrs> { 265 view({attrs}: m.CVnode<NotesEditorPanelAttrs>) { 266 const note = globals.state.notes[attrs.id]; 267 const startTime = 268 getStartTimestamp(note) - globals.state.traceTime.startSec; 269 return m( 270 '.notes-editor-panel', 271 m('.notes-editor-panel-heading-bar', 272 m('.notes-editor-panel-heading', 273 `Annotation at ${timeToString(startTime)}`), 274 m('input[type=text]', { 275 onkeydown: (e: Event) => { 276 e.stopImmediatePropagation(); 277 }, 278 value: note.text, 279 onchange: (e: InputEvent) => { 280 const newText = (e.target as HTMLInputElement).value; 281 globals.dispatch(Actions.changeNoteText({ 282 id: attrs.id, 283 newText, 284 })); 285 }, 286 }), 287 m('span.color-change', `Change color: `, m('input[type=color]', { 288 value: note.color, 289 onchange: (e: Event) => { 290 const newColor = (e.target as HTMLInputElement).value; 291 globals.dispatch(Actions.changeNoteColor({ 292 id: attrs.id, 293 newColor, 294 })); 295 }, 296 })), 297 m('button', 298 { 299 onclick: () => { 300 globals.dispatch(Actions.removeNote({id: attrs.id})); 301 globals.frontendLocalState.currentTab = undefined; 302 globals.rafScheduler.scheduleFullRedraw(); 303 } 304 }, 305 'Remove')), 306 ); 307 } 308 309 renderCanvas(_ctx: CanvasRenderingContext2D, _size: PanelSize) {} 310} 311