| // 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 {currentTargetOffset} from '../base/dom_utils'; |
| import {Icons} from '../base/semantic_icons'; |
| import {time} from '../base/time'; |
| import {Actions} from '../common/actions'; |
| import {TrackCacheEntry} from '../common/track_cache'; |
| import {raf} from '../core/raf_scheduler'; |
| import {SliceRect, Track, TrackTags} from '../public'; |
| |
| import {checkerboard} from './checkerboard'; |
| import {SELECTION_FILL_COLOR, TRACK_SHELL_WIDTH} from './css_constants'; |
| import {globals} from './globals'; |
| import {drawGridLines} from './gridline_helper'; |
| import {PanelSize} from './panel'; |
| import {Panel} from './panel_container'; |
| import {verticalScrollToTrack} from './scroll_helper'; |
| import {drawVerticalLineAtTime} from './vertical_line_helper'; |
| import {classNames} from '../base/classnames'; |
| import {Button, ButtonBar} from '../widgets/button'; |
| import {Popup} from '../widgets/popup'; |
| import {canvasClip} from '../common/canvas_utils'; |
| import {TimeScale} from './time_scale'; |
| import {getLegacySelection} from '../common/state'; |
| import {CloseTrackButton} from './close_track_button'; |
| import {exists} from '../base/utils'; |
| import {Intent} from '../widgets/common'; |
| |
| 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 = getLegacySelection(globals.state); |
| if (selection === null || selection.kind !== 'AREA') return false; |
| const selectedArea = globals.state.areas[selection.areaId]; |
| return selectedArea.tracks.includes(id); |
| } |
| |
| interface TrackChipAttrs { |
| text: string; |
| } |
| |
| class TrackChip implements m.ClassComponent<TrackChipAttrs> { |
| view({attrs}: m.CVnode<TrackChipAttrs>) { |
| return m('span.chip', attrs.text); |
| } |
| } |
| |
| export function renderChips(tags?: TrackTags) { |
| return [ |
| tags?.metric && m(TrackChip, {text: 'metric'}), |
| tags?.debuggable && m(TrackChip, {text: 'debuggable'}), |
| ]; |
| } |
| |
| export interface CrashButtonAttrs { |
| error: Error; |
| } |
| |
| export class CrashButton implements m.ClassComponent<CrashButtonAttrs> { |
| view({attrs}: m.Vnode<CrashButtonAttrs>): m.Children { |
| return m( |
| Popup, |
| { |
| trigger: m(Button, { |
| icon: Icons.Crashed, |
| compact: true, |
| }), |
| }, |
| this.renderErrorMessage(attrs.error), |
| ); |
| } |
| |
| private renderErrorMessage(error: Error): m.Children { |
| return m( |
| '', |
| 'This track has crashed', |
| m(Button, { |
| label: 'Re-raise exception', |
| intent: Intent.Primary, |
| className: Popup.DISMISS_POPUP_GROUP_CLASS, |
| onclick: () => { |
| throw error; |
| }, |
| }), |
| ); |
| } |
| } |
| |
| interface TrackShellAttrs { |
| trackKey: string; |
| title: string; |
| buttons: m.Children; |
| tags?: TrackTags; |
| button?: string; |
| } |
| |
| 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; |
| |
| view({attrs}: m.CVnode<TrackShellAttrs>) { |
| // The shell should be highlighted if the current search result is inside |
| // this track. |
| let highlightClass = undefined; |
| const searchIndex = globals.state.searchIndex; |
| if (searchIndex !== -1) { |
| const trackKey = globals.currentSearchResults.trackKeys[searchIndex]; |
| if (trackKey === attrs.trackKey) { |
| highlightClass = 'flash'; |
| } |
| } |
| |
| const currentSelection = getLegacySelection(globals.state); |
| const pinned = isPinned(attrs.trackKey); |
| |
| return m( |
| `.track-shell[draggable=true]`, |
| { |
| className: classNames( |
| highlightClass, |
| this.dragging && 'drag', |
| this.dropping && `drop-${this.dropping}`, |
| ), |
| ondragstart: (e: DragEvent) => this.ondragstart(e, attrs.trackKey), |
| ondragend: this.ondragend.bind(this), |
| ondragover: this.ondragover.bind(this), |
| ondragleave: this.ondragleave.bind(this), |
| ondrop: (e: DragEvent) => this.ondrop(e, attrs.trackKey), |
| }, |
| m( |
| '.track-menubar', |
| m( |
| 'h1', |
| { |
| title: attrs.title, |
| style: { |
| 'font-size': getTitleSize(attrs.title), |
| }, |
| }, |
| attrs.title, |
| renderChips(attrs.tags), |
| ), |
| m( |
| ButtonBar, |
| {className: 'track-buttons'}, |
| attrs.buttons, |
| m(Button, { |
| className: classNames(!pinned && 'pf-visible-on-hover'), |
| onclick: () => { |
| globals.dispatch( |
| Actions.toggleTrackPinned({trackKey: attrs.trackKey}), |
| ); |
| }, |
| icon: Icons.Pin, |
| iconFilled: pinned, |
| title: pinned ? 'Unpin' : 'Pin to top', |
| compact: true, |
| }), |
| currentSelection !== null && currentSelection.kind === 'AREA' |
| ? m(Button, { |
| onclick: (e: MouseEvent) => { |
| globals.dispatch( |
| Actions.toggleTrackSelection({ |
| id: attrs.trackKey, |
| isTrackGroup: false, |
| }), |
| ); |
| e.stopPropagation(); |
| }, |
| compact: true, |
| icon: isSelected(attrs.trackKey) |
| ? Icons.Checkbox |
| : Icons.BlankCheckbox, |
| title: isSelected(attrs.trackKey) |
| ? 'Remove track' |
| : 'Add track to selection', |
| }) |
| : '', |
| ), |
| ), |
| ); |
| } |
| |
| ondragstart(e: DragEvent, trackKey: string) { |
| const dataTransfer = e.dataTransfer; |
| if (dataTransfer === null) return; |
| this.dragging = true; |
| raf.scheduleFullRedraw(); |
| dataTransfer.setData('perfetto/track', `${trackKey}`); |
| dataTransfer.setDragImage(new Image(), 0, 0); |
| } |
| |
| ondragend() { |
| this.dragging = false; |
| raf.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'; |
| } |
| raf.scheduleFullRedraw(); |
| } |
| |
| ondragleave() { |
| this.dropping = undefined; |
| raf.scheduleFullRedraw(); |
| } |
| |
| ondrop(e: DragEvent, trackKey: string) { |
| if (this.dropping === undefined) return; |
| const dataTransfer = e.dataTransfer; |
| if (dataTransfer === null) return; |
| raf.scheduleFullRedraw(); |
| const srcId = dataTransfer.getData('perfetto/track'); |
| const dstId = trackKey; |
| globals.dispatch(Actions.moveTrack({srcId, op: this.dropping, dstId})); |
| this.dropping = undefined; |
| } |
| } |
| |
| export interface TrackContentAttrs { |
| track: Track; |
| hasError?: boolean; |
| height?: number; |
| } |
| 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', |
| { |
| style: exists(attrs.height) && { |
| height: `${attrs.height}px`, |
| }, |
| className: classNames(attrs.hasError && 'pf-track-content-error'), |
| onmousemove: (e: MouseEvent) => { |
| attrs.track.onMouseMove?.(currentTargetOffset(e)); |
| raf.scheduleRedraw(); |
| }, |
| onmouseout: () => { |
| attrs.track.onMouseOut?.(); |
| raf.scheduleRedraw(); |
| }, |
| onmousedown: (e: MouseEvent) => { |
| const {x, y} = currentTargetOffset(e); |
| this.mouseDownX = x; |
| this.mouseDownY = y; |
| }, |
| onmouseup: (e: MouseEvent) => { |
| if (this.mouseDownX === undefined || this.mouseDownY === undefined) { |
| return; |
| } |
| const {x, y} = currentTargetOffset(e); |
| if ( |
| Math.abs(x - this.mouseDownX) > 1 || |
| Math.abs(y - this.mouseDownY) > 1 |
| ) { |
| this.selectionOccurred = true; |
| } |
| this.mouseDownX = undefined; |
| this.mouseDownY = undefined; |
| }, |
| onclick: (e: MouseEvent) => { |
| // 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?.(currentTargetOffset(e))) { |
| e.stopPropagation(); |
| } |
| raf.scheduleRedraw(); |
| }, |
| }, |
| node.children, |
| ); |
| } |
| } |
| |
| interface TrackComponentAttrs { |
| trackKey: string; |
| heightPx?: number; |
| title: string; |
| buttons?: m.Children; |
| tags?: TrackTags; |
| track?: Track; |
| error?: Error | undefined; |
| closeable: boolean; |
| |
| // Issues a scrollTo() on this DOM element at creation time. Default: false. |
| revealOnCreate?: boolean; |
| } |
| |
| 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. |
| const TRACK_HEIGHT_MIN_PX = 18; |
| const TRACK_HEIGHT_DEFAULT_PX = 24; |
| const trackHeightRaw = attrs.heightPx ?? TRACK_HEIGHT_DEFAULT_PX; |
| const trackHeight = Math.max(trackHeightRaw, TRACK_HEIGHT_MIN_PX); |
| |
| return m( |
| '.track', |
| { |
| style: { |
| // Note: Sub-pixel track heights can mess with sticky elements. |
| // Round up to the nearest integer number of pixels. |
| height: `${Math.ceil(trackHeight)}px`, |
| }, |
| id: 'track_' + attrs.trackKey, |
| }, |
| [ |
| m(TrackShell, { |
| buttons: [ |
| attrs.error && m(CrashButton, {error: attrs.error}), |
| attrs.closeable && m(CloseTrackButton, {trackKey: attrs.trackKey}), |
| attrs.buttons, |
| ], |
| title: attrs.title, |
| trackKey: attrs.trackKey, |
| tags: attrs.tags, |
| }), |
| attrs.track && |
| m(TrackContent, { |
| track: attrs.track, |
| hasError: Boolean(attrs.error), |
| height: attrs.heightPx, |
| }), |
| ], |
| ); |
| } |
| |
| oncreate(vnode: m.VnodeDOM<TrackComponentAttrs>) { |
| const {attrs} = vnode; |
| if (globals.scrollToTrackKey === attrs.trackKey) { |
| verticalScrollToTrack(attrs.trackKey); |
| globals.scrollToTrackKey = undefined; |
| } |
| this.onupdate(vnode); |
| |
| if (attrs.revealOnCreate) { |
| vnode.dom.scrollIntoView(); |
| } |
| } |
| |
| onupdate(vnode: m.VnodeDOM<TrackComponentAttrs>) { |
| vnode.attrs.track?.onFullRedraw?.(); |
| } |
| } |
| |
| interface TrackPanelAttrs { |
| trackKey: string; |
| title: string; |
| tags?: TrackTags; |
| trackFSM?: TrackCacheEntry; |
| revealOnCreate?: boolean; |
| closeable: boolean; |
| } |
| |
| export class TrackPanel implements Panel { |
| readonly kind = 'panel'; |
| readonly selectable = true; |
| |
| constructor(private readonly attrs: TrackPanelAttrs) {} |
| |
| get key(): string { |
| return this.attrs.trackKey; |
| } |
| |
| get trackKey(): string { |
| return this.attrs.trackKey; |
| } |
| |
| render(): m.Children { |
| const attrs = this.attrs; |
| |
| if (attrs.trackFSM) { |
| if (attrs.trackFSM.getError()) { |
| return m(TrackComponent, { |
| title: attrs.title, |
| trackKey: attrs.trackKey, |
| error: attrs.trackFSM.getError(), |
| track: attrs.trackFSM.track, |
| closeable: attrs.closeable, |
| }); |
| } |
| return m(TrackComponent, { |
| trackKey: attrs.trackKey, |
| title: attrs.title, |
| heightPx: attrs.trackFSM.track.getHeight(), |
| buttons: attrs.trackFSM.track.getTrackShellButtons?.(), |
| tags: attrs.tags, |
| track: attrs.trackFSM.track, |
| error: attrs.trackFSM.getError(), |
| revealOnCreate: attrs.revealOnCreate, |
| closeable: attrs.closeable, |
| }); |
| } else { |
| return m(TrackComponent, { |
| trackKey: attrs.trackKey, |
| title: attrs.title, |
| revealOnCreate: attrs.revealOnCreate, |
| closeable: attrs.closeable, |
| }); |
| } |
| } |
| |
| highlightIfTrackSelected(ctx: CanvasRenderingContext2D, size: PanelSize) { |
| const {visibleTimeScale} = globals.timeline; |
| const selection = getLegacySelection(globals.state); |
| if (!selection || selection.kind !== 'AREA') { |
| return; |
| } |
| const selectedArea = globals.state.areas[selection.areaId]; |
| const selectedAreaDuration = selectedArea.end - selectedArea.start; |
| if (selectedArea.tracks.includes(this.attrs.trackKey)) { |
| ctx.fillStyle = SELECTION_FILL_COLOR; |
| ctx.fillRect( |
| visibleTimeScale.timeToPx(selectedArea.start) + TRACK_SHELL_WIDTH, |
| 0, |
| visibleTimeScale.durationToPx(selectedAreaDuration), |
| size.height, |
| ); |
| } |
| } |
| |
| renderCanvas(ctx: CanvasRenderingContext2D, size: PanelSize) { |
| ctx.save(); |
| canvasClip( |
| ctx, |
| TRACK_SHELL_WIDTH, |
| 0, |
| size.width - TRACK_SHELL_WIDTH, |
| size.height, |
| ); |
| |
| drawGridLines(ctx, size.width, size.height); |
| |
| const track = this.attrs.trackFSM; |
| |
| ctx.save(); |
| ctx.translate(TRACK_SHELL_WIDTH, 0); |
| if (track !== undefined) { |
| const trackSize = {...size, width: size.width - TRACK_SHELL_WIDTH}; |
| if (!track.getError()) { |
| track.update(); |
| track.track.render(ctx, trackSize); |
| } |
| } else { |
| checkerboard(ctx, size.height, 0, size.width - TRACK_SHELL_WIDTH); |
| } |
| ctx.restore(); |
| |
| this.highlightIfTrackSelected(ctx, size); |
| |
| const {visibleTimeScale} = globals.timeline; |
| // Draw vertical line when hovering on the notes panel. |
| renderHoveredNoteVertical(ctx, visibleTimeScale, size); |
| renderHoveredCursorVertical(ctx, visibleTimeScale, size); |
| renderWakeupVertical(ctx, visibleTimeScale, size); |
| renderNoteVerticals(ctx, visibleTimeScale, size); |
| |
| ctx.restore(); |
| } |
| |
| getSliceRect(tStart: time, tDur: time, depth: number): SliceRect | undefined { |
| if (this.attrs.trackFSM === undefined) { |
| return undefined; |
| } |
| return this.attrs.trackFSM.track.getSliceRect?.(tStart, tDur, depth); |
| } |
| } |
| |
| export function renderHoveredCursorVertical( |
| ctx: CanvasRenderingContext2D, |
| visibleTimeScale: TimeScale, |
| size: PanelSize, |
| ) { |
| if (globals.state.hoverCursorTimestamp !== -1n) { |
| drawVerticalLineAtTime( |
| ctx, |
| visibleTimeScale, |
| globals.state.hoverCursorTimestamp, |
| size.height, |
| `#344596`, |
| ); |
| } |
| } |
| |
| export function renderHoveredNoteVertical( |
| ctx: CanvasRenderingContext2D, |
| visibleTimeScale: TimeScale, |
| size: PanelSize, |
| ) { |
| if (globals.state.hoveredNoteTimestamp !== -1n) { |
| drawVerticalLineAtTime( |
| ctx, |
| visibleTimeScale, |
| globals.state.hoveredNoteTimestamp, |
| size.height, |
| `#aaa`, |
| ); |
| } |
| } |
| |
| export function renderWakeupVertical( |
| ctx: CanvasRenderingContext2D, |
| visibleTimeScale: TimeScale, |
| size: PanelSize, |
| ) { |
| const currentSelection = getLegacySelection(globals.state); |
| if (currentSelection !== null) { |
| if ( |
| currentSelection.kind === 'SLICE' && |
| globals.sliceDetails.wakeupTs !== undefined |
| ) { |
| drawVerticalLineAtTime( |
| ctx, |
| visibleTimeScale, |
| globals.sliceDetails.wakeupTs, |
| size.height, |
| `black`, |
| ); |
| } |
| } |
| } |
| |
| export function renderNoteVerticals( |
| ctx: CanvasRenderingContext2D, |
| visibleTimeScale: TimeScale, |
| size: PanelSize, |
| ) { |
| // 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, |
| ); |
| } |
| } |
| } |