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