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