// 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 {Actions} from '../common/actions'; import {HttpRpcState} from '../common/http_rpc_engine'; import { FrontendLocalState as FrontendState, OmniboxState, Timestamped, TimestampedAreaSelection, VisibleState, } from '../common/state'; import {TimeSpan} from '../common/time'; import {randomColor} from './colorizer'; import {globals} from './globals'; import {debounce, ratelimit} from './rate_limiters'; import {TimeScale} from './time_scale'; interface Range { start?: number; end?: number; } function chooseLatest>(current: T, next: T): T { if (next !== current && next.lastUpdate > current.lastUpdate) { return next; } return current; } function capBetween(t: number, start: number, end: number) { return Math.min(Math.max(t, start), end); } // Calculate the space a scrollbar takes up so that we can subtract it from // the canvas width. function calculateScrollbarWidth() { const outer = document.createElement('div'); outer.style.overflowY = 'scroll'; const inner = document.createElement('div'); outer.appendChild(inner); document.body.appendChild(outer); const width = outer.getBoundingClientRect().width - inner.getBoundingClientRect().width; document.body.removeChild(outer); return width; } /** * State that is shared between several frontend components, but not the * controller. This state is updated at 60fps. */ export class FrontendLocalState { visibleWindowTime = new TimeSpan(0, 10); timeScale = new TimeScale(this.visibleWindowTime, [0, 0]); perfDebug = false; hoveredUtid = -1; hoveredPid = -1; hoveredLogsTimestamp = -1; hoveredNoteTimestamp = -1; vidTimestamp = -1; localOnlyMode = false; sidebarVisible = true; showPanningHint = false; visibleTracks = new Set(); prevVisibleTracks = new Set(); searchIndex = -1; currentTab?: string; scrollToTrackId?: string|number; httpRpcState: HttpRpcState = {connected: false}; newVersionAvailable = false; // This is used to calculate the tracks within a Y range for area selection. areaY: Range = {}; // True if the user is in the process of doing an area selection. selectingArea = false; private scrollBarWidth?: number; private _omniboxState: OmniboxState = { lastUpdate: 0, omnibox: '', mode: 'SEARCH', }; private _visibleState: VisibleState = { lastUpdate: 0, startSec: 0, endSec: 10, resolution: 1, }; private _selectedArea: TimestampedAreaSelection = { lastUpdate: 0, }; // TODO: there is some redundancy in the fact that both |visibleWindowTime| // and a |timeScale| have a notion of time range. That should live in one // place only. getScrollbarWidth() { if (this.scrollBarWidth === undefined) { this.scrollBarWidth = calculateScrollbarWidth(); } return this.scrollBarWidth; } togglePerfDebug() { this.perfDebug = !this.perfDebug; globals.rafScheduler.scheduleFullRedraw(); } setHoveredUtidAndPid(utid: number, pid: number) { this.hoveredUtid = utid; this.hoveredPid = pid; globals.rafScheduler.scheduleRedraw(); } // Sets the timestamp at which a vertical line will be drawn. setHoveredLogsTimestamp(ts: number) { if (this.hoveredLogsTimestamp === ts) return; this.hoveredLogsTimestamp = ts; globals.rafScheduler.scheduleRedraw(); } setHoveredNoteTimestamp(ts: number) { if (this.hoveredNoteTimestamp === ts) return; this.hoveredNoteTimestamp = ts; globals.rafScheduler.scheduleRedraw(); } setVidTimestamp(ts: number) { if (this.vidTimestamp === ts) return; this.vidTimestamp = ts; globals.rafScheduler.scheduleRedraw(); } addVisibleTrack(trackId: string) { this.visibleTracks.add(trackId); } setSearchIndex(index: number) { this.searchIndex = index; globals.rafScheduler.scheduleRedraw(); } toggleSidebar() { this.sidebarVisible = !this.sidebarVisible; globals.rafScheduler.scheduleFullRedraw(); } setHttpRpcState(httpRpcState: HttpRpcState) { this.httpRpcState = httpRpcState; globals.rafScheduler.scheduleFullRedraw(); } // Called when beginning a canvas redraw. clearVisibleTracks() { this.prevVisibleTracks = new Set(this.visibleTracks); this.visibleTracks.clear(); } // Called when the canvas redraw is complete. sendVisibleTracks() { if (this.prevVisibleTracks.size !== this.visibleTracks.size || ![...this.prevVisibleTracks].every( value => this.visibleTracks.has(value))) { globals.dispatch( Actions.setVisibleTracks({tracks: Array.from(this.visibleTracks)})); } } mergeState(state: FrontendState): void { this._omniboxState = chooseLatest(this._omniboxState, state.omniboxState); this._visibleState = chooseLatest(this._visibleState, state.visibleState); this._selectedArea = chooseLatest(this._selectedArea, state.selectedArea); if (this._visibleState === state.visibleState) { this.updateLocalTime( new TimeSpan(this._visibleState.startSec, this._visibleState.endSec)); } } private selectAreaDebounced = debounce(() => { globals.dispatch(Actions.selectArea(this._selectedArea)); }, 20); selectArea( startSec: number, endSec: number, tracks = this._selectedArea.area ? this._selectedArea.area.tracks : []) { if (this.currentNoteSelectionEqualToCurrentAreaSelection()) { globals.dispatch(Actions.deselect({})); } this.showPanningHint = true; this._selectedArea = { area: {startSec, endSec, tracks}, lastUpdate: Date.now() / 1000 }; this.selectAreaDebounced(); globals.rafScheduler.scheduleFullRedraw(); } toggleTrackSelection(id: string, isTrackGroup = false) { const area = this._selectedArea.area; if (!area) return; const index = area.tracks.indexOf(id); if (index > -1) { area.tracks.splice(index, 1); if (isTrackGroup) { // Also remove all child tracks. for (const childTrack of globals.state.trackGroups[id].tracks) { const childIndex = area.tracks.indexOf(childTrack); if (childIndex > -1) { area.tracks.splice(childIndex, 1); } } } } else { area.tracks.push(id); if (isTrackGroup) { // Also add all child tracks. for (const childTrack of globals.state.trackGroups[id].tracks) { if (!area.tracks.includes(childTrack)) { area.tracks.push(childTrack); } } } } this._selectedArea.lastUpdate = Date.now() / 1000; this.selectAreaDebounced(); globals.rafScheduler.scheduleFullRedraw(); } toggleLockArea() { if (!this._selectedArea.area) return; if (this.currentNoteSelectionEqualToCurrentAreaSelection()) { if (globals.state.currentSelection != null && globals.state.currentSelection.kind === 'NOTE') { globals.dispatch( Actions.removeNote({id: globals.state.currentSelection.id})); } } else { const color = randomColor(); globals.dispatch(Actions.addAreaNote({ timestamp: this._selectedArea.area.startSec, area: this._selectedArea.area, color })); } globals.rafScheduler.scheduleFullRedraw(); } deselectArea() { // When an area is deselected (and it is marked) also deselect the current // marked selection if it is for the same area. if (this.currentNoteSelectionEqualToCurrentAreaSelection()) { globals.dispatch(Actions.deselect({})); } this._selectedArea = {lastUpdate: Date.now() / 1000}; this.selectAreaDebounced(); globals.frontendLocalState.currentTab = undefined; globals.rafScheduler.scheduleFullRedraw(); } get selectedArea(): TimestampedAreaSelection { return this._selectedArea; } private setOmniboxDebounced = debounce(() => { globals.dispatch(Actions.setOmnibox(this._omniboxState)); }, 20); setOmnibox(value: string, mode: 'SEARCH'|'COMMAND') { this._omniboxState.omnibox = value; this._omniboxState.mode = mode; this._omniboxState.lastUpdate = Date.now() / 1000; this.setOmniboxDebounced(); } get omnibox(): string { return this._omniboxState.omnibox; } private ratelimitedUpdateVisible = ratelimit(() => { globals.dispatch(Actions.setVisibleTraceTime(this._visibleState)); }, 50); private updateLocalTime(ts: TimeSpan) { const traceTime = globals.state.traceTime; const startSec = capBetween(ts.start, traceTime.startSec, traceTime.endSec); const endSec = capBetween(ts.end, traceTime.startSec, traceTime.endSec); this.visibleWindowTime = new TimeSpan(startSec, endSec); this.timeScale.setTimeBounds(this.visibleWindowTime); this.updateResolution(this.timeScale.startPx, this.timeScale.endPx); } // We lock an area selection by adding an area note. When we select the note // it will also select the area but then the user can select other things, // like a slice or different note and the area note will be deselected even // though the area selection remains. So it is useful to know if we currently // have the same area note selected and area selection. private currentNoteSelectionEqualToCurrentAreaSelection() { if (!this._selectedArea.area) return false; if (globals.state.currentSelection != null && globals.state.currentSelection.kind === 'NOTE') { const curNote = globals.state.notes[globals.state.currentSelection.id]; // TODO(taylori): Do the tracks need to be the same too? if (curNote.noteType === 'AREA' && curNote.area.startSec === this._selectedArea.area.startSec && curNote.area.endSec === this._selectedArea.area.endSec) { return true; } } return false; } updateVisibleTime(ts: TimeSpan) { this.updateLocalTime(ts); this._visibleState.lastUpdate = Date.now() / 1000; this._visibleState.startSec = this.visibleWindowTime.start; this._visibleState.endSec = this.visibleWindowTime.end; this._visibleState.resolution = globals.getCurResolution(); this.ratelimitedUpdateVisible(); } updateResolution(pxStart: number, pxEnd: number) { this.timeScale.setLimitsPx(pxStart, pxEnd); this._visibleState.lastUpdate = Date.now() / 1000; this._visibleState.resolution = globals.getCurResolution(); this.ratelimitedUpdateVisible(); } }