xref: /aosp_15_r20/external/perfetto/ui/src/frontend/notes_panel.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
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';
16import {currentTargetOffset} from '../base/dom_utils';
17import {Icons} from '../base/semantic_icons';
18import {randomColor} from '../components/colorizer';
19import {SpanNote, Note} from '../public/note';
20import {raf} from '../core/raf_scheduler';
21import {Button, ButtonBar} from '../widgets/button';
22import {TRACK_SHELL_WIDTH} from './css_constants';
23import {getMaxMajorTicks, generateTicks, TickType} from './gridline_helper';
24import {Size2D} from '../base/geom';
25import {Panel} from './panel_container';
26import {Timestamp} from '../components/widgets/timestamp';
27import {assertUnreachable} from '../base/logging';
28import {DetailsPanel} from '../public/details_panel';
29import {TimeScale} from '../base/time_scale';
30import {canvasClip} from '../base/canvas_utils';
31import {Selection} from '../public/selection';
32import {TraceImpl} from '../core/trace_impl';
33
34const FLAG_WIDTH = 16;
35const AREA_TRIANGLE_WIDTH = 10;
36const FLAG = `\uE153`;
37
38function toSummary(s: string) {
39  const newlineIndex = s.indexOf('\n') > 0 ? s.indexOf('\n') : s.length;
40  return s.slice(0, Math.min(newlineIndex, s.length, 16));
41}
42
43function getStartTimestamp(note: Note | SpanNote) {
44  const noteType = note.noteType;
45  switch (noteType) {
46    case 'SPAN':
47      return note.start;
48    case 'DEFAULT':
49      return note.timestamp;
50    default:
51      assertUnreachable(noteType);
52  }
53}
54
55export class NotesPanel implements Panel {
56  readonly kind = 'panel';
57  readonly selectable = false;
58  private readonly trace: TraceImpl;
59  private timescale?: TimeScale; // The timescale from the last render()
60  private hoveredX: null | number = null;
61  private mouseDragging = false;
62
63  constructor(trace: TraceImpl) {
64    this.trace = trace;
65  }
66
67  render(): m.Children {
68    const allCollapsed = this.trace.workspace.flatTracks.every(
69      (n) => n.collapsed,
70    );
71
72    return m(
73      '.notes-panel',
74      {
75        onmousedown: () => {
76          // If the user clicks & drags, very likely they just want to measure
77          // the time horizontally, not set a flag. This debouncing is done to
78          // avoid setting accidental flags like measuring the time on the brush
79          // timeline.
80          this.mouseDragging = false;
81        },
82        onclick: (e: MouseEvent) => {
83          if (!this.mouseDragging) {
84            const x = currentTargetOffset(e).x - TRACK_SHELL_WIDTH;
85            this.onClick(x);
86            e.stopPropagation();
87          }
88        },
89        onmousemove: (e: MouseEvent) => {
90          this.mouseDragging = true;
91          this.hoveredX = currentTargetOffset(e).x - TRACK_SHELL_WIDTH;
92          raf.scheduleCanvasRedraw();
93        },
94        onmouseenter: (e: MouseEvent) => {
95          this.hoveredX = currentTargetOffset(e).x - TRACK_SHELL_WIDTH;
96          raf.scheduleCanvasRedraw();
97        },
98        onmouseout: () => {
99          this.hoveredX = null;
100          this.trace.timeline.hoveredNoteTimestamp = undefined;
101        },
102      },
103      m(
104        ButtonBar,
105        {className: 'pf-toolbar'},
106        m(Button, {
107          onclick: (e: Event) => {
108            e.preventDefault();
109            if (allCollapsed) {
110              this.trace.commands.runCommand(
111                'perfetto.CoreCommands#ExpandAllGroups',
112              );
113            } else {
114              this.trace.commands.runCommand(
115                'perfetto.CoreCommands#CollapseAllGroups',
116              );
117            }
118          },
119          title: allCollapsed ? 'Expand all' : 'Collapse all',
120          icon: allCollapsed ? 'unfold_more' : 'unfold_less',
121          compact: true,
122        }),
123        m(Button, {
124          onclick: (e: Event) => {
125            e.preventDefault();
126            this.trace.workspace.pinnedTracks.forEach((t) =>
127              this.trace.workspace.unpinTrack(t),
128            );
129            raf.scheduleFullRedraw();
130          },
131          title: 'Clear all pinned tracks',
132          icon: 'clear_all',
133          compact: true,
134        }),
135        // TODO(stevegolton): Re-introduce this when we fix track filtering
136        // m(TextInput, {
137        //   placeholder: 'Filter tracks...',
138        //   title:
139        //     'Track filter - enter one or more comma-separated search terms',
140        //   value: this.trace.state.trackFilterTerm,
141        //   oninput: (e: Event) => {
142        //     const filterTerm = (e.target as HTMLInputElement).value;
143        //     this.trace.dispatch(Actions.setTrackFilterTerm({filterTerm}));
144        //   },
145        // }),
146        // m(Button, {
147        //   type: 'reset',
148        //   icon: 'backspace',
149        //   onclick: () => {
150        //     this.trace.dispatch(
151        //       Actions.setTrackFilterTerm({filterTerm: undefined}),
152        //     );
153        //   },
154        //   title: 'Clear track filter',
155        // }),
156      ),
157    );
158  }
159
160  renderCanvas(ctx: CanvasRenderingContext2D, size: Size2D) {
161    ctx.fillStyle = '#999';
162    ctx.fillRect(TRACK_SHELL_WIDTH - 2, 0, 2, size.height);
163
164    const trackSize = {...size, width: size.width - TRACK_SHELL_WIDTH};
165
166    ctx.save();
167    ctx.translate(TRACK_SHELL_WIDTH, 0);
168    canvasClip(ctx, 0, 0, trackSize.width, trackSize.height);
169    this.renderPanel(ctx, trackSize);
170    ctx.restore();
171  }
172
173  private renderPanel(ctx: CanvasRenderingContext2D, size: Size2D): void {
174    let aNoteIsHovered = false;
175
176    const visibleWindow = this.trace.timeline.visibleWindow;
177    const timescale = new TimeScale(visibleWindow, {
178      left: 0,
179      right: size.width,
180    });
181    const timespan = visibleWindow.toTimeSpan();
182
183    this.timescale = timescale;
184
185    if (size.width > 0 && timespan.duration > 0n) {
186      const maxMajorTicks = getMaxMajorTicks(size.width);
187      const offset = this.trace.timeline.timestampOffset();
188      const tickGen = generateTicks(timespan, maxMajorTicks, offset);
189      for (const {type, time} of tickGen) {
190        const px = Math.floor(timescale.timeToPx(time));
191        if (type === TickType.MAJOR) {
192          ctx.fillRect(px, 0, 1, size.height);
193        }
194      }
195    }
196
197    ctx.textBaseline = 'bottom';
198    ctx.font = '10px Helvetica';
199
200    for (const note of this.trace.notes.notes.values()) {
201      const timestamp = getStartTimestamp(note);
202      // TODO(hjd): We should still render area selection marks in viewport is
203      // *within* the area (e.g. both lhs and rhs are out of bounds).
204      if (
205        (note.noteType === 'DEFAULT' &&
206          !visibleWindow.contains(note.timestamp)) ||
207        (note.noteType === 'SPAN' &&
208          !visibleWindow.overlaps(note.start, note.end))
209      ) {
210        continue;
211      }
212      const currentIsHovered =
213        this.hoveredX !== null && this.hitTestNote(this.hoveredX, note);
214      if (currentIsHovered) aNoteIsHovered = true;
215
216      const selection = this.trace.selection.selection;
217      const isSelected = selection.kind === 'note' && selection.id === note.id;
218      const x = timescale.timeToPx(timestamp);
219      const left = Math.floor(x);
220
221      // Draw flag or marker.
222      if (note.noteType === 'SPAN') {
223        this.drawAreaMarker(
224          ctx,
225          left,
226          Math.floor(timescale.timeToPx(note.end)),
227          note.color,
228          isSelected,
229        );
230      } else {
231        this.drawFlag(ctx, left, size.height, note.color, isSelected);
232      }
233
234      if (note.text) {
235        const summary = toSummary(note.text);
236        const measured = ctx.measureText(summary);
237        // Add a white semi-transparent background for the text.
238        ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
239        ctx.fillRect(
240          left + FLAG_WIDTH + 2,
241          size.height + 2,
242          measured.width + 2,
243          -12,
244        );
245        ctx.fillStyle = '#3c4b5d';
246        ctx.fillText(summary, left + FLAG_WIDTH + 3, size.height + 1);
247      }
248    }
249
250    // A real note is hovered so we don't need to see the preview line.
251    // TODO(hjd): Change cursor to pointer here.
252    if (aNoteIsHovered) {
253      this.trace.timeline.hoveredNoteTimestamp = undefined;
254    }
255
256    // View preview note flag when hovering on notes panel.
257    if (!aNoteIsHovered && this.hoveredX !== null) {
258      const timestamp = timescale.pxToHpTime(this.hoveredX).toTime();
259      if (visibleWindow.contains(timestamp)) {
260        this.trace.timeline.hoveredNoteTimestamp = timestamp;
261        const x = timescale.timeToPx(timestamp);
262        const left = Math.floor(x);
263        this.drawFlag(ctx, left, size.height, '#aaa', /* fill */ true);
264      }
265    }
266
267    ctx.restore();
268  }
269
270  private drawAreaMarker(
271    ctx: CanvasRenderingContext2D,
272    x: number,
273    xEnd: number,
274    color: string,
275    fill: boolean,
276  ) {
277    ctx.fillStyle = color;
278    ctx.strokeStyle = color;
279    const topOffset = 10;
280    // Don't draw in the track shell section.
281    if (x >= 0) {
282      // Draw left triangle.
283      ctx.beginPath();
284      ctx.moveTo(x, topOffset);
285      ctx.lineTo(x, topOffset + AREA_TRIANGLE_WIDTH);
286      ctx.lineTo(x + AREA_TRIANGLE_WIDTH, topOffset);
287      ctx.lineTo(x, topOffset);
288      if (fill) ctx.fill();
289      ctx.stroke();
290    }
291    // Draw right triangle.
292    ctx.beginPath();
293    ctx.moveTo(xEnd, topOffset);
294    ctx.lineTo(xEnd, topOffset + AREA_TRIANGLE_WIDTH);
295    ctx.lineTo(xEnd - AREA_TRIANGLE_WIDTH, topOffset);
296    ctx.lineTo(xEnd, topOffset);
297    if (fill) ctx.fill();
298    ctx.stroke();
299
300    // Start line after track shell section, join triangles.
301    const startDraw = Math.max(x, 0);
302    ctx.beginPath();
303    ctx.moveTo(startDraw, topOffset);
304    ctx.lineTo(xEnd, topOffset);
305    ctx.stroke();
306  }
307
308  private drawFlag(
309    ctx: CanvasRenderingContext2D,
310    x: number,
311    height: number,
312    color: string,
313    fill?: boolean,
314  ) {
315    const prevFont = ctx.font;
316    const prevBaseline = ctx.textBaseline;
317    ctx.textBaseline = 'alphabetic';
318    // Adjust height for icon font.
319    ctx.font = '24px Material Symbols Sharp';
320    ctx.fillStyle = color;
321    ctx.strokeStyle = color;
322    // The ligatures have padding included that means the icon is not drawn
323    // exactly at the x value. This adjusts for that.
324    const iconPadding = 6;
325    if (fill) {
326      ctx.fillText(FLAG, x - iconPadding, height + 2);
327    } else {
328      ctx.strokeText(FLAG, x - iconPadding, height + 2.5);
329    }
330    ctx.font = prevFont;
331    ctx.textBaseline = prevBaseline;
332  }
333
334  private onClick(x: number) {
335    if (!this.timescale) {
336      return;
337    }
338
339    // Select the hovered note, or create a new single note & select it
340    if (x < 0) return;
341    for (const note of this.trace.notes.notes.values()) {
342      if (this.hoveredX !== null && this.hitTestNote(this.hoveredX, note)) {
343        this.trace.selection.selectNote({id: note.id});
344        return;
345      }
346    }
347    const timestamp = this.timescale.pxToHpTime(x).toTime();
348    const color = randomColor();
349    const noteId = this.trace.notes.addNote({timestamp, color});
350    this.trace.selection.selectNote({id: noteId});
351  }
352
353  private hitTestNote(x: number, note: SpanNote | Note): boolean {
354    if (!this.timescale) {
355      return false;
356    }
357
358    const timescale = this.timescale;
359    const noteX = timescale.timeToPx(getStartTimestamp(note));
360    if (note.noteType === 'SPAN') {
361      return (
362        (noteX <= x && x < noteX + AREA_TRIANGLE_WIDTH) ||
363        (timescale.timeToPx(note.end) > x &&
364          x > timescale.timeToPx(note.end) - AREA_TRIANGLE_WIDTH)
365      );
366    } else {
367      const width = FLAG_WIDTH;
368      return noteX <= x && x < noteX + width;
369    }
370  }
371}
372
373export class NotesEditorTab implements DetailsPanel {
374  constructor(private trace: TraceImpl) {}
375
376  render(selection: Selection) {
377    if (selection.kind !== 'note') {
378      return undefined;
379    }
380
381    const id = selection.id;
382
383    const note = this.trace.notes.getNote(id);
384    if (note === undefined) {
385      return m('.', `No Note with id ${id}`);
386    }
387    const startTime = getStartTimestamp(note);
388    return m(
389      '.notes-editor-panel',
390      {
391        key: id, // Every note shoul get its own brand new DOM.
392      },
393      m(
394        '.notes-editor-panel-heading-bar',
395        m(
396          '.notes-editor-panel-heading',
397          `Annotation at `,
398          m(Timestamp, {ts: startTime}),
399        ),
400        m('input[type=text]', {
401          oncreate: (v: m.VnodeDOM) => {
402            // NOTE: due to bad design decisions elsewhere this component is
403            // rendered every time the mouse moves on the canvas. We cannot set
404            // `value: note.text` as an input as that will clobber the input
405            // value as we move the mouse.
406            const inputElement = v.dom as HTMLInputElement;
407            inputElement.value = note.text;
408            inputElement.focus();
409          },
410          onchange: (e: InputEvent) => {
411            const newText = (e.target as HTMLInputElement).value;
412            this.trace.notes.changeNote(id, {text: newText});
413          },
414        }),
415        m(
416          'span.color-change',
417          `Change color: `,
418          m('input[type=color]', {
419            value: note.color,
420            onchange: (e: Event) => {
421              const newColor = (e.target as HTMLInputElement).value;
422              this.trace.notes.changeNote(id, {color: newColor});
423            },
424          }),
425        ),
426        m(Button, {
427          label: 'Remove',
428          icon: Icons.Delete,
429          onclick: () => this.trace.notes.removeNote(id),
430        }),
431      ),
432    );
433  }
434}
435