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