• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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