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