blob: ac5b015931956023bc852231dbcb1a1b976394ef [file] [log] [blame]
// Copyright (C) 2019 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use size file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import m from 'mithril';
import {currentTargetOffset} from '../base/dom_utils';
import {Icons} from '../base/semantic_icons';
import {randomColor} from '../public/lib/colorizer';
import {SpanNote, Note} from '../public/note';
import {raf} from '../core/raf_scheduler';
import {Button, ButtonBar} from '../widgets/button';
import {TRACK_SHELL_WIDTH} from './css_constants';
import {getMaxMajorTicks, generateTicks, TickType} from './gridline_helper';
import {Size2D} from '../base/geom';
import {Panel} from './panel_container';
import {Timestamp} from './widgets/timestamp';
import {assertUnreachable} from '../base/logging';
import {DetailsPanel} from '../public/details_panel';
import {TimeScale} from '../base/time_scale';
import {canvasClip} from '../base/canvas_utils';
import {Selection} from '../public/selection';
import {TraceImpl} from '../core/trace_impl';
const FLAG_WIDTH = 16;
const AREA_TRIANGLE_WIDTH = 10;
const FLAG = `\uE153`;
function toSummary(s: string) {
const newlineIndex = s.indexOf('\n') > 0 ? s.indexOf('\n') : s.length;
return s.slice(0, Math.min(newlineIndex, s.length, 16));
}
function getStartTimestamp(note: Note | SpanNote) {
const noteType = note.noteType;
switch (noteType) {
case 'SPAN':
return note.start;
case 'DEFAULT':
return note.timestamp;
default:
assertUnreachable(noteType);
}
}
export class NotesPanel implements Panel {
readonly kind = 'panel';
readonly selectable = false;
private readonly trace: TraceImpl;
private timescale?: TimeScale; // The timescale from the last render()
private hoveredX: null | number = null;
private mouseDragging = false;
constructor(trace: TraceImpl) {
this.trace = trace;
}
render(): m.Children {
const allCollapsed = this.trace.workspace.flatTracks.every(
(n) => n.collapsed,
);
return m(
'.notes-panel',
{
onmousedown: () => {
// If the user clicks & drags, very likely they just want to measure
// the time horizontally, not set a flag. This debouncing is done to
// avoid setting accidental flags like measuring the time on the brush
// timeline.
this.mouseDragging = false;
},
onclick: (e: MouseEvent) => {
if (!this.mouseDragging) {
const x = currentTargetOffset(e).x - TRACK_SHELL_WIDTH;
this.onClick(x);
e.stopPropagation();
}
},
onmousemove: (e: MouseEvent) => {
this.mouseDragging = true;
this.hoveredX = currentTargetOffset(e).x - TRACK_SHELL_WIDTH;
raf.scheduleCanvasRedraw();
},
onmouseenter: (e: MouseEvent) => {
this.hoveredX = currentTargetOffset(e).x - TRACK_SHELL_WIDTH;
raf.scheduleCanvasRedraw();
},
onmouseout: () => {
this.hoveredX = null;
this.trace.timeline.hoveredNoteTimestamp = undefined;
},
},
m(
ButtonBar,
{className: 'pf-toolbar'},
m(Button, {
onclick: (e: Event) => {
e.preventDefault();
if (allCollapsed) {
this.trace.commands.runCommand(
'perfetto.CoreCommands#ExpandAllGroups',
);
} else {
this.trace.commands.runCommand(
'perfetto.CoreCommands#CollapseAllGroups',
);
}
},
title: allCollapsed ? 'Expand all' : 'Collapse all',
icon: allCollapsed ? 'unfold_more' : 'unfold_less',
compact: true,
}),
m(Button, {
onclick: (e: Event) => {
e.preventDefault();
this.trace.workspace.pinnedTracks.forEach((t) =>
this.trace.workspace.unpinTrack(t),
);
raf.scheduleFullRedraw();
},
title: 'Clear all pinned tracks',
icon: 'clear_all',
compact: true,
}),
// TODO(stevegolton): Re-introduce this when we fix track filtering
// m(TextInput, {
// placeholder: 'Filter tracks...',
// title:
// 'Track filter - enter one or more comma-separated search terms',
// value: this.trace.state.trackFilterTerm,
// oninput: (e: Event) => {
// const filterTerm = (e.target as HTMLInputElement).value;
// this.trace.dispatch(Actions.setTrackFilterTerm({filterTerm}));
// },
// }),
// m(Button, {
// type: 'reset',
// icon: 'backspace',
// onclick: () => {
// this.trace.dispatch(
// Actions.setTrackFilterTerm({filterTerm: undefined}),
// );
// },
// title: 'Clear track filter',
// }),
),
);
}
renderCanvas(ctx: CanvasRenderingContext2D, size: Size2D) {
ctx.fillStyle = '#999';
ctx.fillRect(TRACK_SHELL_WIDTH - 2, 0, 2, size.height);
const trackSize = {...size, width: size.width - TRACK_SHELL_WIDTH};
ctx.save();
ctx.translate(TRACK_SHELL_WIDTH, 0);
canvasClip(ctx, 0, 0, trackSize.width, trackSize.height);
this.renderPanel(ctx, trackSize);
ctx.restore();
}
private renderPanel(ctx: CanvasRenderingContext2D, size: Size2D): void {
let aNoteIsHovered = false;
const visibleWindow = this.trace.timeline.visibleWindow;
const timescale = new TimeScale(visibleWindow, {
left: 0,
right: size.width,
});
const timespan = visibleWindow.toTimeSpan();
this.timescale = timescale;
if (size.width > 0 && timespan.duration > 0n) {
const maxMajorTicks = getMaxMajorTicks(size.width);
const offset = this.trace.timeline.timestampOffset();
const tickGen = generateTicks(timespan, maxMajorTicks, offset);
for (const {type, time} of tickGen) {
const px = Math.floor(timescale.timeToPx(time));
if (type === TickType.MAJOR) {
ctx.fillRect(px, 0, 1, size.height);
}
}
}
ctx.textBaseline = 'bottom';
ctx.font = '10px Helvetica';
for (const note of this.trace.notes.notes.values()) {
const timestamp = getStartTimestamp(note);
// TODO(hjd): We should still render area selection marks in viewport is
// *within* the area (e.g. both lhs and rhs are out of bounds).
if (
(note.noteType === 'DEFAULT' &&
!visibleWindow.contains(note.timestamp)) ||
(note.noteType === 'SPAN' &&
!visibleWindow.overlaps(note.start, note.end))
) {
continue;
}
const currentIsHovered =
this.hoveredX !== null && this.hitTestNote(this.hoveredX, note);
if (currentIsHovered) aNoteIsHovered = true;
const selection = this.trace.selection.selection;
const isSelected = selection.kind === 'note' && selection.id === note.id;
const x = timescale.timeToPx(timestamp);
const left = Math.floor(x);
// Draw flag or marker.
if (note.noteType === 'SPAN') {
this.drawAreaMarker(
ctx,
left,
Math.floor(timescale.timeToPx(note.end)),
note.color,
isSelected,
);
} else {
this.drawFlag(ctx, left, size.height, note.color, isSelected);
}
if (note.text) {
const summary = toSummary(note.text);
const measured = ctx.measureText(summary);
// Add a white semi-transparent background for the text.
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
ctx.fillRect(
left + FLAG_WIDTH + 2,
size.height + 2,
measured.width + 2,
-12,
);
ctx.fillStyle = '#3c4b5d';
ctx.fillText(summary, left + FLAG_WIDTH + 3, size.height + 1);
}
}
// A real note is hovered so we don't need to see the preview line.
// TODO(hjd): Change cursor to pointer here.
if (aNoteIsHovered) {
this.trace.timeline.hoveredNoteTimestamp = undefined;
}
// View preview note flag when hovering on notes panel.
if (!aNoteIsHovered && this.hoveredX !== null) {
const timestamp = timescale.pxToHpTime(this.hoveredX).toTime();
if (visibleWindow.contains(timestamp)) {
this.trace.timeline.hoveredNoteTimestamp = timestamp;
const x = timescale.timeToPx(timestamp);
const left = Math.floor(x);
this.drawFlag(ctx, left, size.height, '#aaa', /* fill */ true);
}
}
ctx.restore();
}
private drawAreaMarker(
ctx: CanvasRenderingContext2D,
x: number,
xEnd: number,
color: string,
fill: boolean,
) {
ctx.fillStyle = color;
ctx.strokeStyle = color;
const topOffset = 10;
// Don't draw in the track shell section.
if (x >= 0) {
// Draw left triangle.
ctx.beginPath();
ctx.moveTo(x, topOffset);
ctx.lineTo(x, topOffset + AREA_TRIANGLE_WIDTH);
ctx.lineTo(x + AREA_TRIANGLE_WIDTH, topOffset);
ctx.lineTo(x, topOffset);
if (fill) ctx.fill();
ctx.stroke();
}
// Draw right triangle.
ctx.beginPath();
ctx.moveTo(xEnd, topOffset);
ctx.lineTo(xEnd, topOffset + AREA_TRIANGLE_WIDTH);
ctx.lineTo(xEnd - AREA_TRIANGLE_WIDTH, topOffset);
ctx.lineTo(xEnd, topOffset);
if (fill) ctx.fill();
ctx.stroke();
// Start line after track shell section, join triangles.
const startDraw = Math.max(x, 0);
ctx.beginPath();
ctx.moveTo(startDraw, topOffset);
ctx.lineTo(xEnd, topOffset);
ctx.stroke();
}
private drawFlag(
ctx: CanvasRenderingContext2D,
x: number,
height: number,
color: string,
fill?: boolean,
) {
const prevFont = ctx.font;
const prevBaseline = ctx.textBaseline;
ctx.textBaseline = 'alphabetic';
// Adjust height for icon font.
ctx.font = '24px Material Symbols Sharp';
ctx.fillStyle = color;
ctx.strokeStyle = color;
// The ligatures have padding included that means the icon is not drawn
// exactly at the x value. This adjusts for that.
const iconPadding = 6;
if (fill) {
ctx.fillText(FLAG, x - iconPadding, height + 2);
} else {
ctx.strokeText(FLAG, x - iconPadding, height + 2.5);
}
ctx.font = prevFont;
ctx.textBaseline = prevBaseline;
}
private onClick(x: number) {
if (!this.timescale) {
return;
}
// Select the hovered note, or create a new single note & select it
if (x < 0) return;
for (const note of this.trace.notes.notes.values()) {
if (this.hoveredX !== null && this.hitTestNote(this.hoveredX, note)) {
this.trace.selection.selectNote({id: note.id});
return;
}
}
const timestamp = this.timescale.pxToHpTime(x).toTime();
const color = randomColor();
const noteId = this.trace.notes.addNote({timestamp, color});
this.trace.selection.selectNote({id: noteId});
}
private hitTestNote(x: number, note: SpanNote | Note): boolean {
if (!this.timescale) {
return false;
}
const timescale = this.timescale;
const noteX = timescale.timeToPx(getStartTimestamp(note));
if (note.noteType === 'SPAN') {
return (
(noteX <= x && x < noteX + AREA_TRIANGLE_WIDTH) ||
(timescale.timeToPx(note.end) > x &&
x > timescale.timeToPx(note.end) - AREA_TRIANGLE_WIDTH)
);
} else {
const width = FLAG_WIDTH;
return noteX <= x && x < noteX + width;
}
}
}
export class NotesEditorTab implements DetailsPanel {
constructor(private trace: TraceImpl) {}
render(selection: Selection) {
if (selection.kind !== 'note') {
return undefined;
}
const id = selection.id;
const note = this.trace.notes.getNote(id);
if (note === undefined) {
return m('.', `No Note with id ${id}`);
}
const startTime = getStartTimestamp(note);
return m(
'.notes-editor-panel',
{
key: id, // Every note shoul get its own brand new DOM.
},
m(
'.notes-editor-panel-heading-bar',
m(
'.notes-editor-panel-heading',
`Annotation at `,
m(Timestamp, {ts: startTime}),
),
m('input[type=text]', {
oncreate: (v: m.VnodeDOM) => {
// NOTE: due to bad design decisions elsewhere this component is
// rendered every time the mouse moves on the canvas. We cannot set
// `value: note.text` as an input as that will clobber the input
// value as we move the mouse.
const inputElement = v.dom as HTMLInputElement;
inputElement.value = note.text;
inputElement.focus();
},
onchange: (e: InputEvent) => {
const newText = (e.target as HTMLInputElement).value;
this.trace.notes.changeNote(id, {text: newText});
},
}),
m(
'span.color-change',
`Change color: `,
m('input[type=color]', {
value: note.color,
onchange: (e: Event) => {
const newColor = (e.target as HTMLInputElement).value;
this.trace.notes.changeNote(id, {color: newColor});
},
}),
),
m(Button, {
label: 'Remove',
icon: Icons.Delete,
onclick: () => this.trace.notes.removeNote(id),
}),
),
);
}
}