• 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';
16import {canvasClip} from '../../base/canvas_utils';
17import {currentTargetOffset, findRef} from '../../base/dom_utils';
18import {Size2D} from '../../base/geom';
19import {assertUnreachable} from '../../base/logging';
20import {Icons} from '../../base/semantic_icons';
21import {TimeScale} from '../../base/time_scale';
22import {randomColor} from '../../components/colorizer';
23import {raf} from '../../core/raf_scheduler';
24import {TraceImpl} from '../../core/trace_impl';
25import {Note, SpanNote} from '../../public/note';
26import {Button, ButtonBar} from '../../widgets/button';
27import {MenuDivider, MenuItem, PopupMenu} from '../../widgets/menu';
28import {Select} from '../../widgets/select';
29import {TRACK_SHELL_WIDTH} from '../css_constants';
30import {generateTicks, getMaxMajorTicks, TickType} from './gridline_helper';
31import {TextInput} from '../../widgets/text_input';
32import {Popup} from '../../widgets/popup';
33import {TrackNode, Workspace} from '../../public/workspace';
34import {AreaSelection, Selection} from '../../public/selection';
35import {MultiSelectOption, PopupMultiSelect} from '../../widgets/multiselect';
36
37const FLAG_WIDTH = 16;
38const AREA_TRIANGLE_WIDTH = 10;
39const FLAG = `\uE153`;
40
41function toSummary(s: string) {
42  const newlineIndex = s.indexOf('\n') > 0 ? s.indexOf('\n') : s.length;
43  return s.slice(0, Math.min(newlineIndex, s.length, 16));
44}
45
46function getStartTimestamp(note: Note | SpanNote) {
47  const noteType = note.noteType;
48  switch (noteType) {
49    case 'SPAN':
50      return note.start;
51    case 'DEFAULT':
52      return note.timestamp;
53    default:
54      assertUnreachable(noteType);
55  }
56}
57
58const FILTER_TEXT_BOX_REF = 'filter-text-box';
59
60export class NotesPanel {
61  private readonly trace: TraceImpl;
62  private timescale?: TimeScale; // The timescale from the last render()
63  private hoveredX: null | number = null;
64  private mouseDragging = false;
65  readonly height = 20;
66
67  constructor(trace: TraceImpl) {
68    this.trace = trace;
69  }
70
71  render(): m.Children {
72    const allCollapsed = this.trace.workspace.flatTracks.every(
73      (n) => n.collapsed,
74    );
75
76    const workspaces = this.trace.workspaces;
77    const selection = this.trace.selection.selection;
78
79    return m(
80      '',
81      {
82        style: {height: `${this.height}px`},
83        onmousedown: () => {
84          // If the user clicks & drags, very likely they just want to measure
85          // the time horizontally, not set a flag. This debouncing is done to
86          // avoid setting accidental flags like measuring the time on the brush
87          // timeline.
88          this.mouseDragging = false;
89        },
90        onclick: (e: MouseEvent) => {
91          if (!this.mouseDragging) {
92            const x = currentTargetOffset(e).x - TRACK_SHELL_WIDTH;
93            this.onClick(x);
94            e.stopPropagation();
95          }
96        },
97        onmousemove: (e: MouseEvent) => {
98          this.mouseDragging = true;
99          this.hoveredX = currentTargetOffset(e).x - TRACK_SHELL_WIDTH;
100          raf.scheduleCanvasRedraw();
101        },
102        onmouseenter: (e: MouseEvent) => {
103          this.hoveredX = currentTargetOffset(e).x - TRACK_SHELL_WIDTH;
104          raf.scheduleCanvasRedraw();
105        },
106        onmouseout: () => {
107          this.hoveredX = null;
108          this.trace.timeline.hoveredNoteTimestamp = undefined;
109        },
110      },
111      m(
112        ButtonBar,
113        {className: 'pf-timeline-toolbar'},
114        m(Button, {
115          onclick: (e: Event) => {
116            e.preventDefault();
117            if (allCollapsed) {
118              this.trace.commands.runCommand(
119                'perfetto.CoreCommands#ExpandAllGroups',
120              );
121            } else {
122              this.trace.commands.runCommand(
123                'perfetto.CoreCommands#CollapseAllGroups',
124              );
125            }
126          },
127          title: allCollapsed ? 'Expand all' : 'Collapse all',
128          icon: allCollapsed ? 'unfold_more' : 'unfold_less',
129          compact: true,
130        }),
131        m(Button, {
132          onclick: (e: Event) => {
133            e.preventDefault();
134            this.trace.workspace.pinnedTracks.forEach((t) =>
135              this.trace.workspace.unpinTrack(t),
136            );
137          },
138          title: 'Clear all pinned tracks',
139          icon: 'clear_all',
140          compact: true,
141        }),
142        this.renderTrackFilter(),
143        m(
144          Select,
145          {
146            className: 'pf-timeline-toolbar__workspace-selector',
147            onchange: async (e) => {
148              const value = (e.target as HTMLSelectElement).value;
149              if (value === 'new-workspace') {
150                const ws =
151                  workspaces.createEmptyWorkspace('Untitled Workspace');
152                workspaces.switchWorkspace(ws);
153              } else {
154                const ws = workspaces.all.find(({id}) => id === value);
155                ws && this.trace?.workspaces.switchWorkspace(ws);
156              }
157            },
158          },
159          workspaces.all
160            .map((ws) => {
161              return m('option', {
162                value: `${ws.id}`,
163                label: ws.title,
164                selected: ws === this.trace?.workspace,
165              });
166            })
167            .concat([
168              m('option', {
169                value: 'new-workspace',
170                label: 'New workspace...',
171              }),
172            ]),
173        ),
174        m(
175          PopupMenu,
176          {
177            trigger: m(Button, {
178              icon: 'more_vert',
179              title: 'Workspace options',
180              compact: true,
181            }),
182          },
183          this.renderCopySelectedTracksToWorkspace(selection),
184          m(MenuDivider),
185          this.renderNewGroupButton(),
186          m(MenuDivider),
187          m(MenuItem, {
188            icon: 'edit',
189            label: 'Rename current workspace',
190            disabled: !this.trace.workspace.userEditable,
191            title: this.trace.workspace.userEditable
192              ? 'Create new group'
193              : 'This workspace is not editable - please create a new workspace if you wish to modify it',
194            onclick: async () => {
195              const newName = await this.trace.omnibox.prompt(
196                'Enter a new name...',
197              );
198              if (newName) {
199                workspaces.currentWorkspace.title = newName;
200              }
201            },
202          }),
203          m(MenuItem, {
204            icon: Icons.Delete,
205            label: 'Delete current workspace',
206            disabled: !this.trace.workspace.userEditable,
207            title: this.trace.workspace.userEditable
208              ? 'Create new group'
209              : 'This workspace is not editable - please create a new workspace if you wish to modify it',
210            onclick: () => {
211              workspaces.removeWorkspace(workspaces.currentWorkspace);
212            },
213          }),
214        ),
215      ),
216    );
217  }
218
219  private renderTrackFilter() {
220    const trackFilters = this.trace.tracks.filters;
221
222    return m(
223      Popup,
224      {
225        trigger: m(Button, {
226          icon: 'filter_alt',
227          title: 'Track filter',
228          compact: true,
229          iconFilled: trackFilters.areFiltersSet(),
230        }),
231      },
232      m(
233        'form.pf-track-filter',
234        {
235          oncreate({dom}) {
236            // Focus & select text box when the popup opens.
237            const input = findRef(dom, FILTER_TEXT_BOX_REF) as HTMLInputElement;
238            input.focus();
239            input.select();
240          },
241        },
242        m(
243          '.pf-track-filter__row',
244          m('label', {for: 'filter-name'}, 'Filter by name'),
245          m(TextInput, {
246            ref: FILTER_TEXT_BOX_REF,
247            id: 'filter-name',
248            placeholder: 'Filter by name...',
249            title: 'Filter by name (comma separated terms)',
250            value: trackFilters.nameFilter,
251            oninput: (e: Event) => {
252              const value = (e.target as HTMLInputElement).value;
253              trackFilters.nameFilter = value;
254            },
255          }),
256        ),
257        this.trace.tracks.trackFilterCriteria.map((filter) => {
258          return m(
259            '.pf-track-filter__row',
260            m('label', 'Filter by ', filter.name),
261            m(PopupMultiSelect, {
262              label: filter.name,
263              showNumSelected: true,
264              // It usually doesn't make sense to select all filters - if users
265              // want to pass all they should just remove the filters instead.
266              showSelectAllButton: false,
267              onChange: (diff) => {
268                for (const {id, checked} of diff) {
269                  if (checked) {
270                    // Add the filter option to the criteria.
271                    const criteriaFilters = trackFilters.criteriaFilters.get(
272                      filter.name,
273                    );
274                    if (criteriaFilters) {
275                      criteriaFilters.push(id);
276                    } else {
277                      trackFilters.criteriaFilters.set(filter.name, [id]);
278                    }
279                  } else {
280                    // Remove the filter option from the criteria.
281                    const filterOptions = trackFilters.criteriaFilters.get(
282                      filter.name,
283                    );
284
285                    if (!filterOptions) continue;
286                    const newOptions = filterOptions.filter((f) => f !== id);
287                    if (newOptions.length === 0) {
288                      trackFilters.criteriaFilters.delete(filter.name);
289                    } else {
290                      trackFilters.criteriaFilters.set(filter.name, newOptions);
291                    }
292                  }
293                }
294              },
295              options: filter.options
296                .map((o): MultiSelectOption => {
297                  const filterOptions = trackFilters.criteriaFilters.get(
298                    filter.name,
299                  );
300                  const checked = Boolean(
301                    filterOptions && filterOptions.includes(o.key),
302                  );
303                  return {id: o.key, name: o.label, checked};
304                })
305                .filter((f) => f.name !== ''),
306            }),
307          );
308        }),
309        m(Button, {
310          type: 'reset',
311          label: 'Clear All Filters',
312          icon: 'filter_alt_off',
313          onclick: () => {
314            trackFilters.clearAll();
315          },
316        }),
317      ),
318    );
319  }
320
321  private renderNewGroupButton() {
322    return m(MenuItem, {
323      icon: 'create_new_folder',
324      label: 'Create new group track',
325      disabled: !this.trace.workspace.userEditable,
326      title: this.trace.workspace.userEditable
327        ? 'Create new group'
328        : 'This workspace is not editable - please create a new workspace if you wish to modify it',
329      onclick: async () => {
330        const result = await this.trace.omnibox.prompt('Group name...');
331        if (result) {
332          const group = new TrackNode({title: result, isSummary: true});
333          this.trace.workspace.addChildLast(group);
334        }
335      },
336    });
337  }
338
339  private renderCopySelectedTracksToWorkspace(selection: Selection) {
340    const isArea = selection.kind === 'area';
341    return [
342      m(
343        MenuItem,
344        {
345          label: 'Copy selected tracks to workspace',
346          disabled: !isArea,
347          title: isArea
348            ? 'Copy selected tracks to workspace'
349            : 'Please create an area selection to copy tracks',
350        },
351        this.trace.workspaces.all.map((ws) =>
352          m(MenuItem, {
353            label: ws.title,
354            disabled: !ws.userEditable,
355            onclick: isArea
356              ? () => this.copySelectedToWorkspace(ws, selection)
357              : undefined,
358          }),
359        ),
360        m(MenuDivider),
361        m(MenuItem, {
362          label: 'New workspace...',
363          onclick: isArea
364            ? () => this.copySelectedToWorkspace(undefined, selection)
365            : undefined,
366        }),
367      ),
368      m(
369        MenuItem,
370        {
371          label: 'Copy selected tracks & switch to workspace',
372          disabled: !isArea,
373          title: isArea
374            ? 'Copy selected tracks to workspace and switch to that workspace'
375            : 'Please create an area selection to copy tracks',
376        },
377        this.trace.workspaces.all.map((ws) =>
378          m(MenuItem, {
379            label: ws.title,
380            disabled: !ws.userEditable,
381            onclick: isArea
382              ? async () => {
383                  this.copySelectedToWorkspace(ws, selection);
384                  this.trace.workspaces.switchWorkspace(ws);
385                }
386              : undefined,
387          }),
388        ),
389        m(MenuDivider),
390        m(MenuItem, {
391          label: 'New workspace...',
392          onclick: isArea
393            ? async () => {
394                const ws = this.copySelectedToWorkspace(undefined, selection);
395                this.trace.workspaces.switchWorkspace(ws);
396              }
397            : undefined,
398        }),
399      ),
400    ];
401  }
402
403  private copySelectedToWorkspace(
404    ws: Workspace | undefined,
405    selection: AreaSelection,
406  ) {
407    // If no workspace provided, create a new one.
408    if (!ws) {
409      ws = this.trace.workspaces.createEmptyWorkspace('Untitled Workspace');
410    }
411    for (const track of selection.tracks) {
412      const node = this.trace.workspace.getTrackByUri(track.uri);
413      if (!node) continue;
414      const newNode = node.clone();
415      ws.addChildLast(newNode);
416    }
417    return ws;
418  }
419
420  renderCanvas(ctx: CanvasRenderingContext2D, size: Size2D) {
421    ctx.fillStyle = '#999';
422    ctx.fillRect(TRACK_SHELL_WIDTH - 1, 0, 1, size.height);
423
424    const trackSize = {...size, width: size.width - TRACK_SHELL_WIDTH};
425
426    ctx.save();
427    ctx.translate(TRACK_SHELL_WIDTH, 0);
428    canvasClip(ctx, 0, 0, trackSize.width, trackSize.height);
429    this.renderPanel(ctx, trackSize);
430    ctx.restore();
431  }
432
433  private renderPanel(ctx: CanvasRenderingContext2D, size: Size2D): void {
434    let aNoteIsHovered = false;
435
436    const visibleWindow = this.trace.timeline.visibleWindow;
437    const timescale = new TimeScale(visibleWindow, {
438      left: 0,
439      right: size.width,
440    });
441    const timespan = visibleWindow.toTimeSpan();
442
443    this.timescale = timescale;
444
445    if (size.width > 0 && timespan.duration > 0n) {
446      const maxMajorTicks = getMaxMajorTicks(size.width);
447      const offset = this.trace.timeline.timestampOffset();
448      const tickGen = generateTicks(timespan, maxMajorTicks, offset);
449      for (const {type, time} of tickGen) {
450        const px = Math.floor(timescale.timeToPx(time));
451        if (type === TickType.MAJOR) {
452          ctx.fillRect(px, 0, 1, size.height);
453        }
454      }
455    }
456
457    ctx.textBaseline = 'bottom';
458    ctx.font = '10px Helvetica';
459
460    for (const note of this.trace.notes.notes.values()) {
461      const timestamp = getStartTimestamp(note);
462      // TODO(hjd): We should still render area selection marks in viewport is
463      // *within* the area (e.g. both lhs and rhs are out of bounds).
464      if (
465        (note.noteType === 'DEFAULT' &&
466          !visibleWindow.contains(note.timestamp)) ||
467        (note.noteType === 'SPAN' &&
468          !visibleWindow.overlaps(note.start, note.end))
469      ) {
470        continue;
471      }
472      const currentIsHovered =
473        this.hoveredX !== null && this.hitTestNote(this.hoveredX, note);
474      if (currentIsHovered) aNoteIsHovered = true;
475
476      const selection = this.trace.selection.selection;
477      const isSelected = selection.kind === 'note' && selection.id === note.id;
478      const x = timescale.timeToPx(timestamp);
479      const left = Math.floor(x);
480
481      // Draw flag or marker.
482      if (note.noteType === 'SPAN') {
483        this.drawAreaMarker(
484          ctx,
485          left,
486          Math.floor(timescale.timeToPx(note.end)),
487          note.color,
488          isSelected,
489        );
490      } else {
491        this.drawFlag(ctx, left, size.height, note.color, isSelected);
492      }
493
494      if (note.text) {
495        const summary = toSummary(note.text);
496        const measured = ctx.measureText(summary);
497        // Add a white semi-transparent background for the text.
498        ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
499        ctx.fillRect(
500          left + FLAG_WIDTH + 2,
501          size.height + 2,
502          measured.width + 2,
503          -12,
504        );
505        ctx.fillStyle = '#3c4b5d';
506        ctx.fillText(summary, left + FLAG_WIDTH + 3, size.height + 1);
507      }
508    }
509
510    // A real note is hovered so we don't need to see the preview line.
511    // TODO(hjd): Change cursor to pointer here.
512    if (aNoteIsHovered) {
513      this.trace.timeline.hoveredNoteTimestamp = undefined;
514    }
515
516    // View preview note flag when hovering on notes panel.
517    if (!aNoteIsHovered && this.hoveredX !== null) {
518      const timestamp = timescale.pxToHpTime(this.hoveredX).toTime();
519      if (visibleWindow.contains(timestamp)) {
520        this.trace.timeline.hoveredNoteTimestamp = timestamp;
521        const x = timescale.timeToPx(timestamp);
522        const left = Math.floor(x);
523        this.drawFlag(ctx, left, size.height, '#aaa', /* fill */ true);
524      }
525    }
526
527    ctx.restore();
528  }
529
530  private drawAreaMarker(
531    ctx: CanvasRenderingContext2D,
532    x: number,
533    xEnd: number,
534    color: string,
535    fill: boolean,
536  ) {
537    ctx.fillStyle = color;
538    ctx.strokeStyle = color;
539    const topOffset = 10;
540    // Don't draw in the track shell section.
541    if (x >= 0) {
542      // Draw left triangle.
543      ctx.beginPath();
544      ctx.moveTo(x, topOffset);
545      ctx.lineTo(x, topOffset + AREA_TRIANGLE_WIDTH);
546      ctx.lineTo(x + AREA_TRIANGLE_WIDTH, topOffset);
547      ctx.lineTo(x, topOffset);
548      if (fill) ctx.fill();
549      ctx.stroke();
550    }
551    // Draw right triangle.
552    ctx.beginPath();
553    ctx.moveTo(xEnd, topOffset);
554    ctx.lineTo(xEnd, topOffset + AREA_TRIANGLE_WIDTH);
555    ctx.lineTo(xEnd - AREA_TRIANGLE_WIDTH, topOffset);
556    ctx.lineTo(xEnd, topOffset);
557    if (fill) ctx.fill();
558    ctx.stroke();
559
560    // Start line after track shell section, join triangles.
561    const startDraw = Math.max(x, 0);
562    ctx.beginPath();
563    ctx.moveTo(startDraw, topOffset);
564    ctx.lineTo(xEnd, topOffset);
565    ctx.stroke();
566  }
567
568  private drawFlag(
569    ctx: CanvasRenderingContext2D,
570    x: number,
571    height: number,
572    color: string,
573    fill?: boolean,
574  ) {
575    const prevFont = ctx.font;
576    const prevBaseline = ctx.textBaseline;
577    ctx.textBaseline = 'alphabetic';
578    // Adjust height for icon font.
579    ctx.font = '24px Material Symbols Sharp';
580    ctx.fillStyle = color;
581    ctx.strokeStyle = color;
582    // The ligatures have padding included that means the icon is not drawn
583    // exactly at the x value. This adjusts for that.
584    const iconPadding = 6;
585    if (fill) {
586      ctx.fillText(FLAG, x - iconPadding, height + 2);
587    } else {
588      ctx.strokeText(FLAG, x - iconPadding, height + 2.5);
589    }
590    ctx.font = prevFont;
591    ctx.textBaseline = prevBaseline;
592  }
593
594  private onClick(x: number) {
595    if (!this.timescale) {
596      return;
597    }
598
599    // Select the hovered note, or create a new single note & select it
600    if (x < 0) return;
601    for (const note of this.trace.notes.notes.values()) {
602      if (this.hoveredX !== null && this.hitTestNote(this.hoveredX, note)) {
603        this.trace.selection.selectNote({id: note.id});
604        return;
605      }
606    }
607    const timestamp = this.timescale.pxToHpTime(x).toTime();
608    const color = randomColor();
609    const noteId = this.trace.notes.addNote({timestamp, color});
610    this.trace.selection.selectNote({id: noteId});
611  }
612
613  private hitTestNote(x: number, note: SpanNote | Note): boolean {
614    if (!this.timescale) {
615      return false;
616    }
617
618    const timescale = this.timescale;
619    const noteX = timescale.timeToPx(getStartTimestamp(note));
620    if (note.noteType === 'SPAN') {
621      return (
622        (noteX <= x && x < noteX + AREA_TRIANGLE_WIDTH) ||
623        (timescale.timeToPx(note.end) > x &&
624          x > timescale.timeToPx(note.end) - AREA_TRIANGLE_WIDTH)
625      );
626    } else {
627      const width = FLAG_WIDTH;
628      return noteX <= x && x < noteX + width;
629    }
630  }
631}
632