blob: 4c7b2a51a81c3adfe44d75624ec8f39317182425 [file] [log] [blame]
// Copyright (C) 2018 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this 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 {hex} from 'color-convert';
import m from 'mithril';
import {Actions} from '../common/actions';
import {TrackState} from '../common/state';
import {TPTime} from '../common/time';
import {SELECTION_FILL_COLOR, TRACK_SHELL_WIDTH} from './css_constants';
import {PerfettoMouseEvent} from './events';
import {globals} from './globals';
import {drawGridLines} from './gridline_helper';
import {BLANK_CHECKBOX, CHECKBOX, PIN} from './icons';
import {Panel, PanelSize} from './panel';
import {verticalScrollToTrack} from './scroll_helper';
import {SliceRect, Track} from './track';
import {trackRegistry} from './track_registry';
import {
drawVerticalLineAtTime,
} from './vertical_line_helper';
function getTitleSize(title: string): string|undefined {
const length = title.length;
if (length > 55) {
return '9px';
}
if (length > 50) {
return '10px';
}
if (length > 45) {
return '11px';
}
if (length > 40) {
return '12px';
}
if (length > 35) {
return '13px';
}
return undefined;
}
function isPinned(id: string) {
return globals.state.pinnedTracks.indexOf(id) !== -1;
}
function isSelected(id: string) {
const selection = globals.state.currentSelection;
if (selection === null || selection.kind !== 'AREA') return false;
const selectedArea = globals.state.areas[selection.areaId];
return selectedArea.tracks.includes(id);
}
interface TrackShellAttrs {
track: Track;
trackState: TrackState;
}
class TrackShell implements m.ClassComponent<TrackShellAttrs> {
// Set to true when we click down and drag the
private dragging = false;
private dropping: 'before'|'after'|undefined = undefined;
private attrs?: TrackShellAttrs;
oninit(vnode: m.Vnode<TrackShellAttrs>) {
this.attrs = vnode.attrs;
}
view({attrs}: m.CVnode<TrackShellAttrs>) {
// The shell should be highlighted if the current search result is inside
// this track.
let highlightClass = '';
const searchIndex = globals.state.searchIndex;
if (searchIndex !== -1) {
const trackId = globals.currentSearchResults.trackIds[searchIndex];
if (trackId === attrs.trackState.id) {
highlightClass = 'flash';
}
}
const dragClass = this.dragging ? `drag` : '';
const dropClass = this.dropping ? `drop-${this.dropping}` : '';
return m(
`.track-shell[draggable=true]`,
{
class: `${highlightClass} ${dragClass} ${dropClass}`,
ondragstart: this.ondragstart.bind(this),
ondragend: this.ondragend.bind(this),
ondragover: this.ondragover.bind(this),
ondragleave: this.ondragleave.bind(this),
ondrop: this.ondrop.bind(this),
},
m(
'h1',
{
title: attrs.trackState.name,
style: {
'font-size': getTitleSize(attrs.trackState.name),
},
},
attrs.trackState.name,
('namespace' in attrs.trackState.config) &&
m('span.chip', 'metric'),
),
m('.track-buttons',
attrs.track.getTrackShellButtons(),
attrs.track.getContextMenu(),
m(TrackButton, {
action: () => {
globals.dispatch(
Actions.toggleTrackPinned({trackId: attrs.trackState.id}));
},
i: PIN,
filledIcon: isPinned(attrs.trackState.id),
tooltip: isPinned(attrs.trackState.id) ? 'Unpin' : 'Pin to top',
showButton: isPinned(attrs.trackState.id),
fullHeight: true,
}),
globals.state.currentSelection !== null &&
globals.state.currentSelection.kind === 'AREA' ?
m(TrackButton, {
action: (e: PerfettoMouseEvent) => {
globals.dispatch(Actions.toggleTrackSelection(
{id: attrs.trackState.id, isTrackGroup: false}));
e.stopPropagation();
},
i: isSelected(attrs.trackState.id) ? CHECKBOX : BLANK_CHECKBOX,
tooltip: isSelected(attrs.trackState.id) ?
'Remove track' :
'Add track to selection',
showButton: true,
}) :
''));
}
ondragstart(e: DragEvent) {
const dataTransfer = e.dataTransfer;
if (dataTransfer === null) return;
this.dragging = true;
globals.rafScheduler.scheduleFullRedraw();
dataTransfer.setData('perfetto/track', `${this.attrs!.trackState.id}`);
dataTransfer.setDragImage(new Image(), 0, 0);
}
ondragend() {
this.dragging = false;
globals.rafScheduler.scheduleFullRedraw();
}
ondragover(e: DragEvent) {
if (this.dragging) return;
if (!(e.target instanceof HTMLElement)) return;
const dataTransfer = e.dataTransfer;
if (dataTransfer === null) return;
if (!dataTransfer.types.includes('perfetto/track')) return;
dataTransfer.dropEffect = 'move';
e.preventDefault();
// Apply some hysteresis to the drop logic so that the lightened border
// changes only when we get close enough to the border.
if (e.offsetY < e.target.scrollHeight / 3) {
this.dropping = 'before';
} else if (e.offsetY > e.target.scrollHeight / 3 * 2) {
this.dropping = 'after';
}
globals.rafScheduler.scheduleFullRedraw();
}
ondragleave() {
this.dropping = undefined;
globals.rafScheduler.scheduleFullRedraw();
}
ondrop(e: DragEvent) {
if (this.dropping === undefined) return;
const dataTransfer = e.dataTransfer;
if (dataTransfer === null) return;
globals.rafScheduler.scheduleFullRedraw();
const srcId = dataTransfer.getData('perfetto/track');
const dstId = this.attrs!.trackState.id;
globals.dispatch(Actions.moveTrack({srcId, op: this.dropping, dstId}));
this.dropping = undefined;
}
}
export interface TrackContentAttrs { track: Track; }
export class TrackContent implements m.ClassComponent<TrackContentAttrs> {
private mouseDownX?: number;
private mouseDownY?: number;
private selectionOccurred = false;
view(node: m.CVnode<TrackContentAttrs>) {
const attrs = node.attrs;
return m(
'.track-content',
{
onmousemove: (e: PerfettoMouseEvent) => {
attrs.track.onMouseMove(
{x: e.layerX - TRACK_SHELL_WIDTH, y: e.layerY});
globals.rafScheduler.scheduleRedraw();
},
onmouseout: () => {
attrs.track.onMouseOut();
globals.rafScheduler.scheduleRedraw();
},
onmousedown: (e: PerfettoMouseEvent) => {
this.mouseDownX = e.layerX;
this.mouseDownY = e.layerY;
},
onmouseup: (e: PerfettoMouseEvent) => {
if (this.mouseDownX === undefined ||
this.mouseDownY === undefined) {
return;
}
if (Math.abs(e.layerX - this.mouseDownX) > 1 ||
Math.abs(e.layerY - this.mouseDownY) > 1) {
this.selectionOccurred = true;
}
this.mouseDownX = undefined;
this.mouseDownY = undefined;
},
onclick: (e: PerfettoMouseEvent) => {
// This click event occurs after any selection mouse up/drag events
// so we have to look if the mouse moved during this click to know
// if a selection occurred.
if (this.selectionOccurred) {
this.selectionOccurred = false;
return;
}
// Returns true if something was selected, so stop propagation.
if (attrs.track.onMouseClick(
{x: e.layerX - TRACK_SHELL_WIDTH, y: e.layerY})) {
e.stopPropagation();
}
globals.rafScheduler.scheduleRedraw();
},
},
node.children);
}
}
interface TrackComponentAttrs {
trackState: TrackState;
track: Track;
}
class TrackComponent implements m.ClassComponent<TrackComponentAttrs> {
view({attrs}: m.CVnode<TrackComponentAttrs>) {
// TODO(hjd): The min height below must match the track_shell_title
// max height in common.scss so we should read it from CSS to avoid
// them going out of sync.
return m(
'.track',
{
style: {
height: `${Math.max(18, attrs.track.getHeight())}px`,
},
id: 'track_' + attrs.trackState.id,
},
[
m(TrackShell, {track: attrs.track, trackState: attrs.trackState}),
m(TrackContent, {track: attrs.track}),
]);
}
oncreate({attrs}: m.CVnode<TrackComponentAttrs>) {
if (globals.frontendLocalState.scrollToTrackId === attrs.trackState.id) {
verticalScrollToTrack(attrs.trackState.id);
globals.frontendLocalState.scrollToTrackId = undefined;
}
}
}
export interface TrackButtonAttrs {
action: (e: PerfettoMouseEvent) => void;
i: string;
tooltip: string;
showButton: boolean;
fullHeight?: boolean;
filledIcon?: boolean;
}
export class TrackButton implements m.ClassComponent<TrackButtonAttrs> {
view({attrs}: m.CVnode<TrackButtonAttrs>) {
return m(
'i.track-button',
{
class: [
(attrs.showButton ? 'show' : ''),
(attrs.fullHeight ? 'full-height' : ''),
(attrs.filledIcon ? 'material-icons-filled' : 'material-icons'),
].filter(Boolean)
.join(' '),
onclick: attrs.action,
title: attrs.tooltip,
},
attrs.i);
}
}
interface TrackPanelAttrs {
id: string;
selectable: boolean;
}
export class TrackPanel extends Panel<TrackPanelAttrs> {
// TODO(hjd): It would be nicer if these could not be undefined here.
// We should implement a NullTrack which can be used if the trackState
// has disappeared.
private track: Track|undefined;
private trackState: TrackState|undefined;
constructor(vnode: m.CVnode<TrackPanelAttrs>) {
super();
const trackId = vnode.attrs.id;
const trackState = globals.state.tracks[trackId];
if (trackState === undefined) {
return;
}
const engine = globals.engines.get(trackState.engineId);
if (engine === undefined) {
return;
}
const trackCreator = trackRegistry.get(trackState.kind);
this.track = trackCreator.create({
trackId,
engine:
engine.getProxy(`Track; kind: ${trackState.kind}; id: ${trackId}`),
});
this.trackState = trackState;
}
view() {
if (this.track === undefined || this.trackState === undefined) {
return m('div', 'No such track');
}
return m(TrackComponent, {trackState: this.trackState, track: this.track});
}
oncreate() {
if (this.track !== undefined) {
this.track.onFullRedraw();
}
}
onupdate() {
if (this.track !== undefined) {
this.track.onFullRedraw();
}
}
onremove() {
if (this.track !== undefined) {
this.track.onDestroy();
this.track = undefined;
}
}
highlightIfTrackSelected(ctx: CanvasRenderingContext2D, size: PanelSize) {
const {visibleTimeScale} = globals.frontendLocalState;
const selection = globals.state.currentSelection;
const trackState = this.trackState;
if (!selection || selection.kind !== 'AREA' || trackState === undefined) {
return;
}
const selectedArea = globals.state.areas[selection.areaId];
const selectedAreaDuration = selectedArea.end - selectedArea.start;
if (selectedArea.tracks.includes(trackState.id)) {
ctx.fillStyle = SELECTION_FILL_COLOR;
ctx.fillRect(
visibleTimeScale.tpTimeToPx(selectedArea.start) + TRACK_SHELL_WIDTH,
0,
visibleTimeScale.durationToPx(selectedAreaDuration),
size.height);
}
}
renderCanvas(ctx: CanvasRenderingContext2D, size: PanelSize) {
ctx.save();
drawGridLines(
ctx,
size.width,
size.height);
ctx.translate(TRACK_SHELL_WIDTH, 0);
if (this.track !== undefined) {
this.track.render(ctx);
}
ctx.restore();
this.highlightIfTrackSelected(ctx, size);
const {visibleTimeScale} = globals.frontendLocalState;
// Draw vertical line when hovering on the notes panel.
if (globals.state.hoveredNoteTimestamp !== -1n) {
drawVerticalLineAtTime(
ctx,
visibleTimeScale,
globals.state.hoveredNoteTimestamp,
size.height,
`#aaa`);
}
if (globals.state.hoverCursorTimestamp !== -1n) {
drawVerticalLineAtTime(
ctx,
visibleTimeScale,
globals.state.hoverCursorTimestamp,
size.height,
`#344596`);
}
if (globals.state.currentSelection !== null) {
if (globals.state.currentSelection.kind === 'SLICE' &&
globals.sliceDetails.wakeupTs !== undefined) {
drawVerticalLineAtTime(
ctx,
visibleTimeScale,
globals.sliceDetails.wakeupTs,
size.height,
`black`);
}
}
// All marked areas should have semi-transparent vertical lines
// marking the start and end.
for (const note of Object.values(globals.state.notes)) {
if (note.noteType === 'AREA') {
const transparentNoteColor =
'rgba(' + hex.rgb(note.color.substr(1)).toString() + ', 0.65)';
drawVerticalLineAtTime(
ctx,
visibleTimeScale,
globals.state.areas[note.areaId].start,
size.height,
transparentNoteColor,
1);
drawVerticalLineAtTime(
ctx,
visibleTimeScale,
globals.state.areas[note.areaId].end,
size.height,
transparentNoteColor,
1);
} else if (note.noteType === 'DEFAULT') {
drawVerticalLineAtTime(
ctx, visibleTimeScale, note.timestamp, size.height, note.color);
}
}
}
getSliceRect(tStart: TPTime, tDur: TPTime, depth: number): SliceRect
|undefined {
if (this.track === undefined) {
return undefined;
}
return this.track.getSliceRect(tStart, tDur, depth);
}
}