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