• 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 {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