From ddff4becb1338994375957507f016d62fb54066f Mon Sep 17 00:00:00 2001 From: Primiano Tucci Date: Mon, 9 Sep 2024 14:40:59 +0100 Subject: [PATCH] ui: add unified scrollTo Scrolling to tracks/slices is very common all over the codebase. Currently we have a plethora of functions with different entrypoints. Furthermore "selection" and "scrolling to things" are quite intertwined. This CL introduces a unified scrollTo function to be used everywhere. This is NOT the final solution, as it seems in 90% of cases we want to scrollTo something as a result of a selection, so perhaps it should be selctionManager to do that, rather than callsites. But that is too big of a refactoring, so i'm doing things in steps. This helps cleaning up the dependency between plugins and frontend, and brings some sanity unifying all the scrollTo variants into a single entry-point. Change-Id: I79436fb811e50d26ff64e2accc2538af433df432 --- ui/src/common/plugins.ts | 9 + ui/src/core/scroll_helper.ts | 144 ++++++++++++++ ui/src/core/track_manager.ts | 7 + .../scroll_jank_cause_link_utils.ts | 18 +- .../chrome_scroll_jank/scroll_jank_slice.ts | 7 +- ui/src/core_plugins/track_utils/index.ts | 4 +- ui/src/frontend/globals.ts | 22 +-- ui/src/frontend/keyboard_event_handler.ts | 18 +- ui/src/frontend/post_message_handler.ts | 6 +- ui/src/frontend/query_table.ts | 12 +- ui/src/frontend/scroll_helper.ts | 179 ------------------ ui/src/frontend/search_handler.ts | 5 +- ui/src/frontend/slice_details_panel.ts | 8 +- ui/src/frontend/track_panel.ts | 4 +- ui/src/frontend/widgets/sched.ts | 14 +- ui/src/frontend/widgets/slice.ts | 20 +- ui/src/frontend/widgets/thread_state.ts | 8 +- .../dev.perfetto.AndroidCujs/trackUtils.ts | 88 ++------- .../handlers/pinCujScoped.ts | 30 +-- ui/src/public/scroll_helper.ts | 70 +++++++ ui/src/public/selection.ts | 1 + ui/src/public/trace.ts | 5 + .../trace_processor/sql_utils/thread_state.ts | 8 +- 23 files changed, 343 insertions(+), 344 deletions(-) create mode 100644 ui/src/core/scroll_helper.ts delete mode 100644 ui/src/frontend/scroll_helper.ts create mode 100644 ui/src/public/scroll_helper.ts diff --git a/ui/src/common/plugins.ts b/ui/src/common/plugins.ts index 4548ab5f22..fbb88722ee 100644 --- a/ui/src/common/plugins.ts +++ b/ui/src/common/plugins.ts @@ -35,6 +35,8 @@ import {TraceInfo} from '../public/trace_info'; import {Workspace, WorkspaceManager} from '../public/workspace'; import {Migrate, Store} from '../base/store'; import {LegacyDetailsPanel} from '../public/details_panel'; +import {scrollTo, ScrollToArgs} from '../public/scroll_helper'; +import {LegacySelection, SelectionOpts} from '../public/selection'; // Every plugin gets its own PluginContext. This is how we keep track // what each plugin is doing and how we can blame issues on particular @@ -187,6 +189,9 @@ class PluginContextTraceImpl implements Trace, Disposable { get selection() { return globals.selectionManager.selection; }, + setLegacy(args: LegacySelection, opts?: SelectionOpts) { + globals.selectionManager.setLegacy(args, opts); + }, clear() { globals.selectionManager.clear(); }, @@ -205,6 +210,10 @@ class PluginContextTraceImpl implements Trace, Disposable { this.trash.use(unregister); } + scrollTo(args: ScrollToArgs): void { + scrollTo(args); + } + get pluginId(): string { return this.ctx.pluginId; } diff --git a/ui/src/core/scroll_helper.ts b/ui/src/core/scroll_helper.ts new file mode 100644 index 0000000000..23f769d6e2 --- /dev/null +++ b/ui/src/core/scroll_helper.ts @@ -0,0 +1,144 @@ +// Copyright (C) 2024 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 {HighPrecisionTimeSpan} from '../base/high_precision_time_span'; +import {time} from '../base/time'; +import {escapeCSSSelector} from '../base/utils'; +import {ScrollToArgs} from 'src/public/scroll_helper'; +import {TraceInfo} from '../public/trace_info'; +import {Workspace} from '../public/workspace'; +import {raf} from './raf_scheduler'; +import {TimelineImpl} from './timeline'; +import {TrackManagerImpl} from './track_manager'; + +// A helper class to help jumping to tracks and time ranges. +// This class must NOT alter in any way the selection status. That +// responsibility belongs to SelectionManager (which uses this). +export class ScrollHelper { + constructor( + private traceInfo: TraceInfo, + private timeline: TimelineImpl, + private workspace: Workspace, + private trackManager: TrackManagerImpl, + ) {} + + // See comments in ScrollToArgs for the intended semantics. + scrollTo(args: ScrollToArgs) { + const {time, track} = args; + raf.scheduleRedraw(); + + if (time !== undefined) { + if (time.end === undefined) { + this.timeline.panToTimestamp(time.start); + } else if (time.viewPercentage !== undefined) { + this.focusHorizontalRangePercentage( + time.start, + time.end, + time.viewPercentage, + ); + } else { + this.focusHorizontalRange(time.start, time.end); + } + } + + if (track !== undefined) { + this.verticalScrollToTrack(track.uri, track.expandGroup ?? false); + } + } + + private focusHorizontalRangePercentage( + start: time, + end: time, + viewPercentage: number, + ): void { + const aoi = HighPrecisionTimeSpan.fromTime(start, end); + + if (viewPercentage <= 0.0 || viewPercentage > 1.0) { + console.warn( + 'Invalid value for [viewPercentage]. ' + + 'Value must be between 0.0 (exclusive) and 1.0 (inclusive).', + ); + // Default to 50%. + viewPercentage = 0.5; + } + const paddingPercentage = 1.0 - viewPercentage; + const halfPaddingTime = (aoi.duration * paddingPercentage) / 2; + this.timeline.updateVisibleTimeHP(aoi.pad(halfPaddingTime)); + } + + private focusHorizontalRange(start: time, end: time): void { + const visible = this.timeline.visibleWindow; + const aoi = HighPrecisionTimeSpan.fromTime(start, end); + const fillRatio = 5; // Default amount to make the AOI fill the viewport + const padRatio = (fillRatio - 1) / 2; + + // If the area of interest already fills more than half the viewport, zoom + // out so that the AOI fills 20% of the viewport + if (aoi.duration * 2 > visible.duration) { + const padded = aoi.pad(aoi.duration * padRatio); + this.timeline.updateVisibleTimeHP(padded); + } else { + // Center visible window on the middle of the AOI, preserving zoom level. + const newStart = aoi.midpoint.subNumber(visible.duration / 2); + + // Adjust the new visible window if it intersects with the trace boundaries. + // It's needed to make the "update the zoom level if visible window doesn't + // change" logic reliable. + const newVisibleWindow = new HighPrecisionTimeSpan( + newStart, + visible.duration, + ).fitWithin(this.traceInfo.start, this.traceInfo.end); + + // If preserving the zoom doesn't change the visible window, consider this + // to be the "second" hotkey press, so just make the AOI fill 20% of the + // viewport + if (newVisibleWindow.equals(visible)) { + const padded = aoi.pad(aoi.duration * padRatio); + this.timeline.updateVisibleTimeHP(padded); + } else { + this.timeline.updateVisibleTimeHP(newVisibleWindow); + } + } + } + + private verticalScrollToTrack(trackUri: string, openGroup: boolean) { + const dom = document.querySelector('#track_' + escapeCSSSelector(trackUri)); + if (dom) { + // block: 'nearest' means that it will only scroll if the track is not + // currently in view. + dom.scrollIntoView({behavior: 'smooth', block: 'nearest'}); + return; + } + + // If we get here, the element for this track was not present in the DOM, + // this might be because it's inside a collapsed group. + // Find the track node in the current workspace, and reveal it. + const trackNode = this.workspace.getTrackByUri(trackUri); + if (!trackNode) return; + + if (openGroup) { + trackNode.reveal(); + this.trackManager.scrollToTrackUriOnCreate = trackUri; + return; + } + + // Find the first closed ancestor of our target track. + const groupNode = trackNode.closestVisibleAncestor; + if (groupNode) { + document + .querySelector('#track_' + groupNode.uri) + ?.scrollIntoView({behavior: 'smooth', block: 'nearest'}); + } + } +} diff --git a/ui/src/core/track_manager.ts b/ui/src/core/track_manager.ts index f5480fd956..c2fae93c55 100644 --- a/ui/src/core/track_manager.ts +++ b/ui/src/core/track_manager.ts @@ -51,6 +51,13 @@ export interface TrackRenderer { export class TrackManagerImpl implements TrackManager { private tracks = new Registry((x) => x.desc.uri); + // This property is written by scroll_helper.ts and read&cleared by the + // track_panel.ts. This exist for the following use case: the user wants to + // scroll to track X, but X is not visible because it's in a collapsed group. + // So we want to stash this information in a place that track_panel.ts can + // access when creating dom elements. + scrollToTrackUriOnCreate?: string; + registerTrack(trackDesc: TrackDescriptor): Disposable { return this.tracks.register(new TrackFSM(trackDesc)); } diff --git a/ui/src/core_plugins/chrome_scroll_jank/scroll_jank_cause_link_utils.ts b/ui/src/core_plugins/chrome_scroll_jank/scroll_jank_cause_link_utils.ts index cc1fe031ce..a03306c12c 100644 --- a/ui/src/core_plugins/chrome_scroll_jank/scroll_jank_cause_link_utils.ts +++ b/ui/src/core_plugins/chrome_scroll_jank/scroll_jank_cause_link_utils.ts @@ -17,10 +17,6 @@ import {Icons} from '../../base/semantic_icons'; import {duration, Time, time} from '../../base/time'; import {exists} from '../../base/utils'; import {globals} from '../../frontend/globals'; -import { - focusHorizontalRange, - verticalScrollToTrack, -} from '../../frontend/scroll_helper'; import {SliceSqlId} from '../../trace_processor/sql_utils/core_types'; import {Engine} from '../../trace_processor/engine'; import {LONG, NUM, STR} from '../../trace_processor/query_result'; @@ -30,6 +26,7 @@ import { CauseThread, ScrollJankCauseMap, } from './scroll_jank_cause_map'; +import {scrollTo} from '../../public/scroll_helper'; const UNKNOWN_NAME = 'Unknown'; @@ -202,10 +199,17 @@ export function getCauseLink( { icon: Icons.UpdateSelection, onclick: () => { - verticalScrollToTrack(trackUris[0], true); + scrollTo({ + track: {uri: trackUris[0], expandGroup: true}, + }); if (exists(ts) && exists(dur)) { - focusHorizontalRange(ts, Time.fromRaw(ts + dur), 0.3); - + scrollTo({ + time: { + start: ts, + end: Time.fromRaw(ts + dur), + viewPercentage: 0.3, + }, + }); globals.selectionManager.setArea({ start: ts, end: Time.fromRaw(ts + dur), diff --git a/ui/src/core_plugins/chrome_scroll_jank/scroll_jank_slice.ts b/ui/src/core_plugins/chrome_scroll_jank/scroll_jank_slice.ts index 37fb4129db..99ca3c7e16 100644 --- a/ui/src/core_plugins/chrome_scroll_jank/scroll_jank_slice.ts +++ b/ui/src/core_plugins/chrome_scroll_jank/scroll_jank_slice.ts @@ -16,7 +16,6 @@ import m from 'mithril'; import {Icons} from '../../base/semantic_icons'; import {duration, time, Time} from '../../base/time'; import {globals} from '../../frontend/globals'; -import {scrollToTrackAndTs} from '../../frontend/scroll_helper'; import {SliceSqlId} from '../../trace_processor/sql_utils/core_types'; import {Engine} from '../../trace_processor/engine'; import {LONG, NUM} from '../../trace_processor/query_result'; @@ -30,6 +29,7 @@ import { CHROME_EVENT_LATENCY_TRACK_KIND, SCROLL_JANK_V3_TRACK_KIND, } from '../../public/track_kinds'; +import {scrollTo} from '../../public/scroll_helper'; interface BasicSlice { // ID of slice. @@ -189,7 +189,10 @@ export class ScrollJankSliceRef detailsPanelConfig: track.detailsPanelConfig, }); - scrollToTrackAndTs(trackUri, vnode.attrs.ts, true); + scrollTo({ + track: {uri: trackUri, expandGroup: true}, + time: {start: vnode.attrs.ts}, + }); }, }, vnode.attrs.name, diff --git a/ui/src/core_plugins/track_utils/index.ts b/ui/src/core_plugins/track_utils/index.ts index 96e0e647ef..a644b11041 100644 --- a/ui/src/core_plugins/track_utils/index.ts +++ b/ui/src/core_plugins/track_utils/index.ts @@ -17,7 +17,6 @@ import { globals, } from '../../frontend/globals'; import {OmniboxMode} from '../../core/omnibox_manager'; -import {verticalScrollToTrack} from '../../frontend/scroll_helper'; import {Trace} from '../../public/trace'; import {PromptOption} from '../../public/omnibox'; import {PerfettoPlugin, PluginDescriptor} from '../../public/plugin'; @@ -61,8 +60,7 @@ class TrackUtilsPlugin implements PerfettoPlugin { sortedOptions, ); if (selectedUri === undefined) return; // Prompt cancelled. - - verticalScrollToTrack(selectedUri, true); + ctx.scrollTo({track: {uri: selectedUri, expandGroup: true}}); const traceTime = globals.traceContext; globals.selectionManager.setArea({ start: traceTime.start, diff --git a/ui/src/frontend/globals.ts b/ui/src/frontend/globals.ts index cd6af73633..e9715602b0 100644 --- a/ui/src/frontend/globals.ts +++ b/ui/src/frontend/globals.ts @@ -56,6 +56,8 @@ import {SearchManagerImpl} from '../core/search_manager'; import {SearchResult} from '../public/search'; import {selectCurrentSearchResult} from './search_handler'; import {WorkspaceManagerImpl} from '../core/workspace_manager'; +import {ScrollHelper} from '../core/scroll_helper'; +import {setScrollToFunction} from '../public/scroll_helper'; const INSTANT_FOCUS_DURATION = 1n; const INCOMPLETE_SLICE_DURATION = 30_000n; @@ -218,12 +220,6 @@ class Globals { private _workspaceManager = new WorkspaceManagerImpl(); readonly omnibox = new OmniboxManagerImpl(); - // TODO(primiano): this is a hack to work around circular deps in globals. - // This function is injected by scroll_helper.ts. Sort out once globals are no - // more. - verticalScrollToTrack?: (trackUri: string, openGroup?: boolean) => void; - - scrollToTrackUri?: string; httpRpcState: HttpRpcState = {connected: false}; showPanningHint = false; permalinkHash?: string; @@ -261,17 +257,21 @@ class Globals { // tracks this._trackManager = new TrackManagerImpl(); + const scrollHelper = new ScrollHelper( + traceCtx, + this._timeline, + this._workspaceManager.currentWorkspace, + this._trackManager, + ); + setScrollToFunction((args) => scrollHelper.scrollTo(args)); + this._searchManager = new SearchManagerImpl({ timeline: this._timeline, trackManager: this._trackManager, workspace: this._workspaceManager.currentWorkspace, engine, onResultStep: (step: SearchResult) => { - selectCurrentSearchResult( - step, - this._selectionManager, - assertExists(this.verticalScrollToTrack), - ); + selectCurrentSearchResult(step, this._selectionManager, scrollHelper); }, }); diff --git a/ui/src/frontend/keyboard_event_handler.ts b/ui/src/frontend/keyboard_event_handler.ts index eccb9c844d..04554c9150 100644 --- a/ui/src/frontend/keyboard_event_handler.ts +++ b/ui/src/frontend/keyboard_event_handler.ts @@ -14,8 +14,8 @@ import {exists} from '../base/utils'; import {Actions} from '../common/actions'; +import {scrollTo} from '../public/scroll_helper'; import {Flow, globals} from './globals'; -import {focusHorizontalRange, verticalScrollToTrack} from './scroll_helper'; type Direction = 'Forward' | 'Backward'; @@ -116,16 +116,16 @@ export function moveByFocusedFlow(direction: Direction): void { } } +// TODO(primiano): this will be moved to SelectionManager but I need first to +// disentangle some dependencies. export async function findCurrentSelection() { const selection = globals.selectionManager.legacySelection; - if (selection === null) return; + if (!exists(selection)) return; const range = await globals.findTimeRangeOfSelection(); - if (exists(range)) { - focusHorizontalRange(range.start, range.end); - } - - if (selection.trackUri) { - verticalScrollToTrack(selection.trackUri, true); - } + const trackUri = selection.trackUri; + scrollTo({ + time: exists(range) ? {start: range.start, end: range.end} : undefined, + track: exists(trackUri) ? {uri: trackUri, expandGroup: true} : undefined, + }); } diff --git a/ui/src/frontend/post_message_handler.ts b/ui/src/frontend/post_message_handler.ts index 58d19a293d..d56926fb00 100644 --- a/ui/src/frontend/post_message_handler.ts +++ b/ui/src/frontend/post_message_handler.ts @@ -19,7 +19,7 @@ import {showModal} from '../widgets/modal'; import {initCssConstants} from './css_constants'; import {globals} from './globals'; import {toggleHelp} from './help_modal'; -import {focusHorizontalRange} from './scroll_helper'; +import {scrollTo} from '../public/scroll_helper'; const TRUSTED_ORIGINS_KEY = 'trustedOrigins'; @@ -282,7 +282,9 @@ async function scrollToTimeRange( } else { const start = Time.fromSeconds(postedScrollToRange.timeStart); const end = Time.fromSeconds(postedScrollToRange.timeEnd); - focusHorizontalRange(start, end, postedScrollToRange.viewPercentage); + scrollTo({ + time: {start, end, viewPercentage: postedScrollToRange.viewPercentage}, + }); } } diff --git a/ui/src/frontend/query_table.ts b/ui/src/frontend/query_table.ts index 2b14811531..cfafeff061 100644 --- a/ui/src/frontend/query_table.ts +++ b/ui/src/frontend/query_table.ts @@ -27,7 +27,7 @@ import {queryResponseToClipboard} from './clipboard'; import {downloadData} from './download_utils'; import {globals} from './globals'; import {Router} from './router'; -import {scrollToTrackAndTimeSpan} from './scroll_helper'; +import {scrollTo} from '../public/scroll_helper'; interface QueryTableRowAttrs { row: Row; @@ -150,12 +150,10 @@ class QueryTableRow implements m.ClassComponent { td.tags?.trackIds?.includes(trackId), )?.uri; if (trackUri !== undefined) { - scrollToTrackAndTimeSpan( - trackUri, - sliceStart, - Time.add(sliceStart, sliceDur), - true, - ); + scrollTo({ + track: {uri: trackUri, expandGroup: true}, + time: {start: sliceStart, end: Time.add(sliceStart, sliceDur)}, + }); const sliceId = getSliceId(row); if (sliceId !== undefined) { this.selectSlice(sliceId, trackUri, switchToCurrentSelectionTab); diff --git a/ui/src/frontend/scroll_helper.ts b/ui/src/frontend/scroll_helper.ts deleted file mode 100644 index 0c6ee59501..0000000000 --- a/ui/src/frontend/scroll_helper.ts +++ /dev/null @@ -1,179 +0,0 @@ -// Copyright (C) 2019 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 {time} from '../base/time'; -import {escapeCSSSelector, exists} from '../base/utils'; -import {HighPrecisionTimeSpan} from '../base/high_precision_time_span'; -import {raf} from '../core/raf_scheduler'; -import {globals} from './globals'; -import {TrackNode} from '../public/workspace'; - -// Given a start and end timestamp (in ns), move the viewport to center this -// range and zoom if necessary: -// - If [viewPercentage] is specified, the viewport will be zoomed so that -// the given time range takes up this percentage of the viewport. -// The following scenarios assume [viewPercentage] is undefined. -// - If the new range is more than 50% of the viewport, zoom out to a level -// where -// the range is 1/5 of the viewport. -// - If the new range is already centered, update the zoom level for the -// viewport -// to cover 1/5 of the viewport. -// - Otherwise, preserve the zoom range. -export function focusHorizontalRange( - start: time, - end: time, - viewPercentage?: number, -): void { - if (exists(viewPercentage)) { - focusHorizontalRangePercentage(start, end, viewPercentage); - } else { - focusHorizontalRangeImpl(start, end); - } -} - -// Given a track id, find a track with that id and scroll it into view. If the -// track is nested inside a track group, scroll to that track group instead. -// If |openGroup| then open the track group and scroll to the track. -export function verticalScrollToTrack(trackUri: string, openGroup = false) { - const track = document.querySelector('#track_' + escapeCSSSelector(trackUri)); - - if (track) { - // block: 'nearest' means that it will only scroll if the track is not - // currently in view. - track.scrollIntoView({behavior: 'smooth', block: 'nearest'}); - return; - } - - // If we get here, the element for this track was not present in the DOM, this - // might be because it's inside a collapsed group. - // Find the track node in the current workspace, and reveal it. - const trackNode = globals.workspace.getTrackByUri(trackUri); - if (!trackNode) return; - - if (openGroup) { - trackNode.reveal(); - globals.scrollToTrackUri = trackUri; - return; - } - // Find the first closed ancestor of our target track. - const groupNode = trackNode.closestVisibleAncestor; - if (groupNode) { - document - .querySelector('#track_' + groupNode.uri) - ?.scrollIntoView({behavior: 'smooth', block: 'nearest'}); - } - // If we get here, it means this track isn't in the workspace. - // TODO(stevegolton): Warn the user about this? -} - -globals.verticalScrollToTrack = verticalScrollToTrack; - -export function verticalScrollToTrackNode( - trackNode: TrackNode, - openGroup = false, -) { - if (openGroup) { - trackNode.reveal(); - // globals.scrollToTrackUri = trackUri; - return; - } - // Find the first closed ancestor of our target track. - const groupNode = trackNode.closestVisibleAncestor; - if (groupNode) { - document - .querySelector('#track_' + groupNode.uri) - ?.scrollIntoView({behavior: 'smooth', block: 'nearest'}); - } -} - -// Scroll vertically and horizontally to reach track |track| at |ts|. -export function scrollToTrackAndTs( - trackUri: string, - ts: time, - openGroup = false, -) { - verticalScrollToTrack(trackUri, openGroup); - globals.timeline.panToTimestamp(ts); -} - -// Scroll vertically and horizontally to a track and time range -export function scrollToTrackAndTimeSpan( - trackUri: string, - start: time, - end: time, - openGroup = false, -) { - verticalScrollToTrack(trackUri, openGroup); - focusHorizontalRange(start, end); -} - -function focusHorizontalRangePercentage( - start: time, - end: time, - viewPercentage: number, -): void { - const aoi = HighPrecisionTimeSpan.fromTime(start, end); - - if (viewPercentage <= 0.0 || viewPercentage > 1.0) { - console.warn( - 'Invalid value for [viewPercentage]. ' + - 'Value must be between 0.0 (exclusive) and 1.0 (inclusive).', - ); - // Default to 50%. - viewPercentage = 0.5; - } - const paddingPercentage = 1.0 - viewPercentage; - const halfPaddingTime = (aoi.duration * paddingPercentage) / 2; - globals.timeline.updateVisibleTimeHP(aoi.pad(halfPaddingTime)); - - raf.scheduleRedraw(); -} - -function focusHorizontalRangeImpl(start: time, end: time): void { - const visible = globals.timeline.visibleWindow; - const aoi = HighPrecisionTimeSpan.fromTime(start, end); - const fillRatio = 5; // Default amount to make the AOI fill the viewport - const padRatio = (fillRatio - 1) / 2; - - // If the area of interest already fills more than half the viewport, zoom out - // so that the AOI fills 20% of the viewport - if (aoi.duration * 2 > visible.duration) { - const padded = aoi.pad(aoi.duration * padRatio); - globals.timeline.updateVisibleTimeHP(padded); - } else { - // Center visible window on the middle of the AOI, preserving the zoom level - const newStart = aoi.midpoint.subNumber(visible.duration / 2); - - // Adjust the new visible window if it intersects with the trace boundaries. - // It's needed to make the "update the zoom level if visible window doesn't - // change" logic reliable. - const newVisibleWindow = new HighPrecisionTimeSpan( - newStart, - visible.duration, - ).fitWithin(globals.traceContext.start, globals.traceContext.end); - - // If preserving the zoom doesn't change the visible window, consider this - // to be the "second" hotkey press, so just make the AOI fill 20% of the - // viewport - if (newVisibleWindow.equals(visible)) { - const padded = aoi.pad(aoi.duration * padRatio); - globals.timeline.updateVisibleTimeHP(padded); - } else { - globals.timeline.updateVisibleTimeHP(newVisibleWindow); - } - } - - raf.scheduleRedraw(); -} diff --git a/ui/src/frontend/search_handler.ts b/ui/src/frontend/search_handler.ts index 454f242400..a713905eaa 100644 --- a/ui/src/frontend/search_handler.ts +++ b/ui/src/frontend/search_handler.ts @@ -13,13 +13,14 @@ // limitations under the License. import {assertUnreachable} from '../base/logging'; +import {ScrollHelper} from '../core/scroll_helper'; import {SelectionManagerImpl} from '../core/selection_manager'; import {SearchResult} from '../public/search'; export function selectCurrentSearchResult( step: SearchResult, selectionManager: SelectionManagerImpl, - verticalScrollToTrack: (trackUri: string, openGroup?: boolean) => void, + scrollHelper: ScrollHelper, ) { const {source, eventId, trackUri} = step; if (eventId === undefined) { @@ -27,7 +28,7 @@ export function selectCurrentSearchResult( } switch (source) { case 'track': - verticalScrollToTrack(trackUri, true); + scrollHelper.scrollTo({track: {uri: trackUri, expandGroup: true}}); break; case 'cpu': selectionManager.setLegacy( diff --git a/ui/src/frontend/slice_details_panel.ts b/ui/src/frontend/slice_details_panel.ts index 20f4e54972..59d4dac7be 100644 --- a/ui/src/frontend/slice_details_panel.ts +++ b/ui/src/frontend/slice_details_panel.ts @@ -21,11 +21,11 @@ import {Section} from '../widgets/section'; import {SqlRef} from '../widgets/sql_ref'; import {Tree, TreeNode} from '../widgets/tree'; import {globals, SliceDetails, ThreadDesc} from './globals'; -import {scrollToTrackAndTs} from './scroll_helper'; import {SlicePanel} from './slice_panel'; import {DurationWidget} from './widgets/duration'; import {Timestamp} from './widgets/timestamp'; import {THREAD_STATE_TRACK_KIND} from '../public/track_kinds'; +import {scrollTo} from '../public/scroll_helper'; const MIN_NORMAL_SCHED_PRIORITY = 100; @@ -235,8 +235,10 @@ export class SliceDetailsPanel extends SlicePanel { id: sliceInfo.threadStateId, trackUri: trackDescriptor.uri, }); - - scrollToTrackAndTs(trackDescriptor.uri, sliceInfo.ts, true); + scrollTo({ + track: {uri: trackDescriptor.uri, expandGroup: true}, + time: {start: sliceInfo.ts}, + }); } } diff --git a/ui/src/frontend/track_panel.ts b/ui/src/frontend/track_panel.ts index c2475e3db7..0c5132a912 100644 --- a/ui/src/frontend/track_panel.ts +++ b/ui/src/frontend/track_panel.ts @@ -468,9 +468,9 @@ class TrackComponent implements m.ClassComponent { oncreate(vnode: m.VnodeDOM) { const {attrs} = vnode; - if (globals.scrollToTrackUri === attrs.trackNode.uri) { + if (globals.trackManager.scrollToTrackUriOnCreate === attrs.trackNode.uri) { vnode.dom.scrollIntoView(); - globals.scrollToTrackUri = undefined; + globals.trackManager.scrollToTrackUriOnCreate = undefined; } this.onupdate(vnode); diff --git a/ui/src/frontend/widgets/sched.ts b/ui/src/frontend/widgets/sched.ts index e8dd375c8b..8eafe4f773 100644 --- a/ui/src/frontend/widgets/sched.ts +++ b/ui/src/frontend/widgets/sched.ts @@ -19,7 +19,7 @@ import {Anchor} from '../../widgets/anchor'; import {Icons} from '../../base/semantic_icons'; import {globals} from '../globals'; import {CPU_SLICE_TRACK_KIND} from '../../public/track_kinds'; -import {scrollToTrackAndTs} from '../scroll_helper'; +import {scrollTo} from '../../public/scroll_helper'; interface SchedRefAttrs { id: SchedSqlId; @@ -51,8 +51,10 @@ export function goToSchedSlice(cpu: number, id: SchedSqlId, ts: time) { id, trackUri, }); - - scrollToTrackAndTs(trackUri, ts); + scrollTo({ + track: {uri: trackUri, expandGroup: true}, + time: {start: ts}, + }); } export class SchedRef implements m.ClassComponent { @@ -76,8 +78,10 @@ export class SchedRef implements m.ClassComponent { vnode.attrs.switchToCurrentSelectionTab ?? true, }, ); - - scrollToTrackAndTs(trackUri, vnode.attrs.ts, true); + scrollTo({ + track: {uri: trackUri, expandGroup: true}, + time: {start: vnode.attrs.ts}, + }); }, }, vnode.attrs.name ?? `Sched ${vnode.attrs.id}`, diff --git a/ui/src/frontend/widgets/slice.ts b/ui/src/frontend/widgets/slice.ts index 2fb64556c7..4aa44fede6 100644 --- a/ui/src/frontend/widgets/slice.ts +++ b/ui/src/frontend/widgets/slice.ts @@ -21,13 +21,13 @@ import { import {Anchor} from '../../widgets/anchor'; import {Icons} from '../../base/semantic_icons'; import {globals} from '../globals'; -import {focusHorizontalRange, verticalScrollToTrack} from '../scroll_helper'; import {BigintMath} from '../../base/bigint_math'; import {getSlice, SliceDetails} from '../../trace_processor/sql_utils/slice'; import { createSqlIdRefRenderer, sqlIdRegistry, } from './sql/details/sql_ref_renderer_registry'; +import {scrollTo} from '../../public/scroll_helper'; interface SliceRefAttrs { readonly id: SliceSqlId; @@ -53,14 +53,20 @@ export class SliceRef implements m.ClassComponent { return td.tags?.trackIds?.includes(vnode.attrs.sqlTrackId); }); if (track === undefined) return; - verticalScrollToTrack(track.uri, true); + scrollTo({ + track: { + uri: track.uri, + expandGroup: true, + }, + }); // Clamp duration to 1 - i.e. for instant events const dur = BigintMath.max(1n, vnode.attrs.dur); - focusHorizontalRange( - vnode.attrs.ts, - Time.fromRaw(vnode.attrs.ts + dur), - ); - + scrollTo({ + time: { + start: vnode.attrs.ts, + end: Time.fromRaw(vnode.attrs.ts + dur), + }, + }); globals.selectionManager.setLegacy( { kind: 'SLICE', diff --git a/ui/src/frontend/widgets/thread_state.ts b/ui/src/frontend/widgets/thread_state.ts index fb64b08636..04b94636b4 100644 --- a/ui/src/frontend/widgets/thread_state.ts +++ b/ui/src/frontend/widgets/thread_state.ts @@ -22,8 +22,8 @@ import {Anchor} from '../../widgets/anchor'; import {Icons} from '../../base/semantic_icons'; import {globals} from '../globals'; import {THREAD_STATE_TRACK_KIND} from '../../public/track_kinds'; -import {scrollToTrackAndTs} from '../scroll_helper'; import {ThreadState} from '../../trace_processor/sql_utils/thread_state'; +import {scrollTo} from '../../public/scroll_helper'; interface ThreadStateRefAttrs { id: ThreadStateSqlId; @@ -67,8 +67,10 @@ export class ThreadStateRef implements m.ClassComponent { vnode.attrs.switchToCurrentSelectionTab, }, ); - - scrollToTrackAndTs(trackDescriptor.uri, vnode.attrs.ts, true); + scrollTo({ + track: {uri: trackDescriptor.uri, expandGroup: true}, + time: {start: vnode.attrs.ts}, + }); }, }, vnode.attrs.name ?? `Thread State ${vnode.attrs.id}`, diff --git a/ui/src/plugins/dev.perfetto.AndroidCujs/trackUtils.ts b/ui/src/plugins/dev.perfetto.AndroidCujs/trackUtils.ts index f8378bbece..9cfa7f5e23 100644 --- a/ui/src/plugins/dev.perfetto.AndroidCujs/trackUtils.ts +++ b/ui/src/plugins/dev.perfetto.AndroidCujs/trackUtils.ts @@ -12,15 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {globals} from '../../frontend/globals'; +import {findCurrentSelection} from '../../frontend/keyboard_event_handler'; import {SimpleSliceTrackConfig} from '../../frontend/simple_slice_track'; import {addDebugSliceTrack} from '../../public/debug_tracks'; import {Trace} from '../../public/trace'; -import {TrackDescriptor} from '../../public/track'; -import {findCurrentSelection} from '../../frontend/keyboard_event_handler'; -import {time, Time} from '../../base/time'; -import {BigintMath} from '../../base/bigint_math'; -import {scrollToTrackAndTimeSpan} from '../../frontend/scroll_helper'; /** * Adds debug tracks from SimpleSliceTrackConfig @@ -61,86 +56,31 @@ export function addAndPinSliceTrack( addDebugTrackOnCommand(ctx, config, trackName); } -/** - * Interface for slice identifier - */ -export interface SliceIdentifier { - sliceId?: number; - trackId?: number; - ts?: time; - dur?: bigint; -} - /** * Sets focus on a specific slice within the trace data. * * Takes and adds desired slice to current selection * Retrieves the track key and scrolls to the desired slice - * - * @param {SliceIdentifier} slice slice to focus on with trackId and sliceId */ - -export function focusOnSlice(slice: SliceIdentifier) { - if (slice.sliceId == undefined || slice.trackId == undefined) { - return; - } - const trackId = slice.trackId; - const track = getTrackForTrackId(trackId); - globals.selectionManager.setLegacy( +export function focusOnSlice( + ctx: Trace, + sqlSliceId: number, + sqlTrackId: number, +) { + // Finds the TrackDescriptor associated to the given SQL `tracks(table).id`. + const track = ctx.tracks.findTrack((trackDescriptor) => { + return trackDescriptor?.tags?.trackIds?.includes(sqlTrackId); + }); + ctx.selection.setLegacy( { kind: 'SLICE', - id: slice.sliceId, + id: sqlSliceId, trackUri: track?.uri, table: 'slice', }, { - pendingScrollId: slice.sliceId, + pendingScrollId: sqlSliceId, }, ); - findCurrentSelection; -} - -/** - * Given the trackId of the track, retrieves its corresponding TrackDescriptor. - * - * @param trackId track_id of the track - */ -function getTrackForTrackId(trackId: number): TrackDescriptor | undefined { - return globals.trackManager.findTrack((trackDescriptor) => { - return trackDescriptor?.tags?.trackIds?.includes(trackId); - }); -} - -/** - * Sets focus on a specific time span and a track - * - * Takes a row object pans the view to that time span - * Retrieves the track key and scrolls to the desired track - * - * @param {SliceIdentifier} slice slice to focus on with trackId and time data - */ - -export async function focusOnTimeAndTrack(slice: SliceIdentifier) { - if ( - slice.trackId == undefined || - slice.ts == undefined || - slice.dur == undefined - ) { - return; - } - const trackId = slice.trackId; - const sliceStart = slice.ts; - // row.dur can be negative. Clamp to 1ns. - const sliceDur = BigintMath.max(slice.dur, 1n); - const track = getTrackForTrackId(trackId); - // true for whether to expand the process group the track belongs to - if (track == undefined) { - return; - } - scrollToTrackAndTimeSpan( - track.uri, - sliceStart, - Time.add(sliceStart, sliceDur), - true, - ); + findCurrentSelection(); } diff --git a/ui/src/plugins/dev.perfetto.PinAndroidPerfMetrics/handlers/pinCujScoped.ts b/ui/src/plugins/dev.perfetto.PinAndroidPerfMetrics/handlers/pinCujScoped.ts index adf084d14b..8b6f1a0481 100644 --- a/ui/src/plugins/dev.perfetto.PinAndroidPerfMetrics/handlers/pinCujScoped.ts +++ b/ui/src/plugins/dev.perfetto.PinAndroidPerfMetrics/handlers/pinCujScoped.ts @@ -18,15 +18,13 @@ import { MetricHandler, JankType, } from './metricUtils'; -import {LONG, NUM} from '../../../trace_processor/query_result'; +import {NUM} from '../../../trace_processor/query_result'; import {Trace} from '../../../public/trace'; import {SimpleSliceTrackConfig} from '../../../frontend/simple_slice_track'; import { addAndPinSliceTrack, focusOnSlice, - SliceIdentifier, } from '../../dev.perfetto.AndroidCujs/trackUtils'; -import {Time} from '../../../base/time'; const ENABLE_FOCUS_ON_FIRST_JANK = true; @@ -131,12 +129,9 @@ class PinCujScopedJank implements MetricHandler { }; } - private async findFirstJank( - ctx: Trace, - tableWithJankyFramesName: string, - ): Promise { + private async focusOnFirstJank(ctx: Trace, tableWithJankyFramesName: string) { const queryForFirstJankyFrame = ` - SELECT slice_id, track_id, ts, dur + SELECT slice_id, track_id FROM slice WHERE type = "actual_frame_timeline_slice" AND name = @@ -146,28 +141,13 @@ class PinCujScopedJank implements MetricHandler { `; const queryResult = await ctx.engine.query(queryForFirstJankyFrame); if (queryResult.numRows() === 0) { - return undefined; + return; } const row = queryResult.firstRow({ slice_id: NUM, track_id: NUM, - ts: LONG, - dur: LONG, }); - const slice: SliceIdentifier = { - sliceId: row.slice_id, - trackId: row.track_id, - ts: Time.fromRaw(row.ts), - dur: row.dur, - }; - return slice; - } - - private async focusOnFirstJank(ctx: Trace, tableWithJankyFramesName: string) { - const slice = await this.findFirstJank(ctx, tableWithJankyFramesName); - if (slice) { - focusOnSlice(slice); - } + focusOnSlice(ctx, row.slice_id, row.track_id); } } diff --git a/ui/src/public/scroll_helper.ts b/ui/src/public/scroll_helper.ts new file mode 100644 index 0000000000..e5c6aa9fec --- /dev/null +++ b/ui/src/public/scroll_helper.ts @@ -0,0 +1,70 @@ +// Copyright (C) 2024 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 {time} from '../base/time'; +import {Optional} from '../base/utils'; + +/** + * A helper to scroll to a combination of tracks and time ranges. + * This exist to decouple the selection logic to the scrolling logic. Nothing in + * this file changes the selection status. Use SelectionManager for that. + */ +export interface ScrollToArgs { + // Given a start and end timestamp (in ns), move the viewport to center this + // range and zoom if necessary: + // - If [viewPercentage] is specified, the viewport will be zoomed so that + // the given time range takes up this percentage of the viewport. + // The following scenarios assume [viewPercentage] is undefined. + // - If the new range is more than 50% of the viewport, zoom out to a level + // where + // the range is 1/5 of the viewport. + // - If the new range is already centered, update the zoom level for the + // viewport + // to cover 1/5 of the viewport. + // - Otherwise, preserve the zoom range. + // + time?: { + start: time; + end?: time; + viewPercentage?: number; + }; + // Find the track with a given uri in the current workspace and scroll it into + // view. Iftrack is nested inside a track group, scroll to that track group + // instead. If `expandGroup` == true, open the track group and scroll to the + // track. + // TODO(primiano): 90% of the times we seem to want expandGroup: true, so we + // should probably flip the default value, and pass false in the few places + // where we do NOT want this behavior. + track?: { + uri: string; + expandGroup?: boolean; + }; +} + +// TODO(primiano): remove this injection once we plumb Trace into all the +// components. Right now too many places need this. This is a temporary solution +// to avoid too many invasive refactorings at once. + +type ScrollToFunction = (a: ScrollToArgs) => void; +let _scrollToFunction: Optional = undefined; + +// If a Trace object is avilable, prefer Trace.scrollTo(). It points to the +// same function. +export function scrollTo(args: ScrollToArgs) { + _scrollToFunction?.(args); +} + +export function setScrollToFunction(f: ScrollToFunction | undefined) { + _scrollToFunction = f; +} diff --git a/ui/src/public/selection.ts b/ui/src/public/selection.ts index 1faac67b28..f726884407 100644 --- a/ui/src/public/selection.ts +++ b/ui/src/public/selection.ts @@ -17,6 +17,7 @@ import {GenericSliceDetailsTabConfigBase} from './details_panel'; export interface SelectionManager { readonly selection: Selection; + setLegacy(args: LegacySelection, opts?: SelectionOpts): void; clear(): void; } diff --git a/ui/src/public/trace.ts b/ui/src/public/trace.ts index a3e379c6a4..2e4dec80a1 100644 --- a/ui/src/public/trace.ts +++ b/ui/src/public/trace.ts @@ -22,6 +22,7 @@ import {Timeline} from './timeline'; import {Workspace, WorkspaceManager} from './workspace'; import {LegacyDetailsPanel} from './details_panel'; import {SelectionManager} from './selection'; +import {ScrollToArgs} from './scroll_helper'; /** * The main API endpoint to interact programmaticaly with the UI and alter its @@ -41,6 +42,10 @@ export interface Trace extends App { readonly workspaces: WorkspaceManager; readonly traceInfo: TraceInfo; + // Scrolls to the given track and/or time. Does NOT change the current + // selection. + scrollTo(args: ScrollToArgs): void; + // TODO(primiano): remove this once the Legacy vs non-Legacy details panel is // gone. This method is particularly problematic because the method called // registerDetailsPanel in TabManagerImpl takes a non-Legacy DetailsPanel, but diff --git a/ui/src/trace_processor/sql_utils/thread_state.ts b/ui/src/trace_processor/sql_utils/thread_state.ts index 7916a31356..a6bd634df6 100644 --- a/ui/src/trace_processor/sql_utils/thread_state.ts +++ b/ui/src/trace_processor/sql_utils/thread_state.ts @@ -22,10 +22,10 @@ import { SQLConstraints, } from '../sql_utils'; import {globals} from '../../frontend/globals'; -import {scrollToTrackAndTs} from '../../frontend/scroll_helper'; import {asUtid, SchedSqlId, ThreadStateSqlId} from './core_types'; import {CPU_SLICE_TRACK_KIND} from '../../public/track_kinds'; import {getThreadInfo, ThreadInfo} from './thread'; +import {scrollTo} from '../../public/scroll_helper'; // Representation of a single thread state object, corresponding to // a row for the |thread_slice| table. @@ -138,6 +138,8 @@ export function goToSchedSlice(cpu: number, id: SchedSqlId, ts: time) { id, trackUri: track.uri, }); - - scrollToTrackAndTs(track.uri, ts); + scrollTo({ + track: {uri: track.uri, expandGroup: true}, + time: {start: ts}, + }); }