diff --git a/docs/contributing/ui-plugins.md b/docs/contributing/ui-plugins.md index fce152b9fb7..e518b6fc094 100644 --- a/docs/contributing/ui-plugins.md +++ b/docs/contributing/ui-plugins.md @@ -1478,30 +1478,51 @@ async function selectProcess( This feature allows for creating interactive workflows directly within the omnibox, guided by your plugin. -### Area Selection Tabs +### Selection Tabs -Plugins can register tabs to be displayed in the details panel when an area of -the timeline is selected. +Plugins can register custom subtabs in the bottom details panel for timeline selections. -To register an area selection tab, use the -`trace.selection.registerAreaSelectionTab` method. +Perfetto provides convenience helpers targeting specific selection types, as well as a generic method: + +- `trace.selection.registerTrackEventSelectionTab`: Registers a tab for track events (slices). +- `trace.selection.registerAreaSelectionTab`: Registers a tab for area selections. +- `trace.selection.registerSelectionTab`: Generic registration for any selection type (`Selection` union). + +#### Track Event Selection Tab Example + +```ts +trace.selection.registerTrackEventSelectionTab({ + id: 'my-slice-tab', + name: 'My Slice Tab', + render: (selection) => { + return { + isLoading: false, + content: m('div', `Selected event: ${selection.eventId} on track ${selection.trackUri}`), + }; + }, +}); +``` + +#### Area Selection Tab Example ```ts trace.selection.registerAreaSelectionTab({ id: 'my-area-selection-tab', name: 'My Area Selection Tab', render: (selection) => { - return m('div', `Selected area: ${selection.start} - ${selection.end}`); + return { + isLoading: false, + content: m('div', `Selected area: ${selection.start} - ${selection.end}`), + }; }, }); ``` -The `render` callback should return mithril content to be displayed in the tab. -The `selection` argument is an `AreaSelection` object, which contains -information about the selected area. +The `render` callback should return a `ContentWithLoadingFlag` object (or `undefined` if the tab is not applicable to the current selection). Examples: +- [com.android.AndroidLockContention](https://github.com/google/perfetto/blob/main/ui/src/plugins/com.android.AndroidLockContention/index.ts). - [dev.perfetto.TraceProcessorTrack/index.ts](https://github.com/google/perfetto/blob/main/ui/src/plugins/dev.perfetto.TraceProcessorTrack/index.ts). ### Metric Visualisations diff --git a/ui/src/core/selection_manager.ts b/ui/src/core/selection_manager.ts index a6fc07e5052..66aa94e7e1b 100644 --- a/ui/src/core/selection_manager.ts +++ b/ui/src/core/selection_manager.ts @@ -20,6 +20,8 @@ import type { SelectionManager, TrackEventSelection, AreaSelectionTab, + TrackEventSelectionTab, + SelectionTab, } from '../public/selection'; import {TimeSpan} from '../base/time'; import {raf} from './raf_scheduler'; @@ -58,7 +60,7 @@ export class SelectionManagerImpl implements SelectionManager { Selection, SelectionDetailsPanel >(); - public readonly areaSelectionTabs: AreaSelectionTab[] = []; + public readonly selectionTabs: SelectionTab[] = []; private _currentSelectionSubTab?: string; constructor( @@ -520,8 +522,28 @@ export class SelectionManagerImpl implements SelectionManager { return undefined; } + registerSelectionTab(tab: SelectionTab): void { + this.selectionTabs.push(tab); + } + registerAreaSelectionTab(tab: AreaSelectionTab): void { - this.areaSelectionTabs.push(tab); + this.registerSelectionTab({ + id: tab.id, + name: tab.name, + priority: tab.priority, + render: (selection) => + selection.kind === 'area' ? tab.render(selection) : undefined, + }); + } + + registerTrackEventSelectionTab(tab: TrackEventSelectionTab): void { + this.registerSelectionTab({ + id: tab.id, + name: tab.name, + priority: tab.priority, + render: (selection) => + selection.kind === 'track_event' ? tab.render(selection) : undefined, + }); } get currentSelectionSubTab(): string | undefined { diff --git a/ui/src/core_plugins/dev.perfetto.Timeline/current_selection_tab.ts b/ui/src/core_plugins/dev.perfetto.Timeline/current_selection_tab.ts index acd976afd13..db359934f96 100644 --- a/ui/src/core_plugins/dev.perfetto.Timeline/current_selection_tab.ts +++ b/ui/src/core_plugins/dev.perfetto.Timeline/current_selection_tab.ts @@ -23,6 +23,9 @@ import {Tree, TreeNode} from '../../widgets/tree'; import type { AreaSelection, NoteSelection, + Selection, + SelectionTab, + TrackEventSelection, TrackSelection, } from '../../public/selection'; import {assertUnreachable} from '../../base/assert'; @@ -30,6 +33,79 @@ import {Button, ButtonBar} from '../../widgets/button'; import {NoteEditor} from './note_editor'; import {Gate} from '../../base/mithril_utils'; +interface TabEntry { + readonly id: string; + readonly name: string; + readonly content: m.Children; + readonly isLoading: boolean; + readonly buttons?: m.Children; +} + +function renderTabs( + tabs: ReadonlyArray, + selection: Selection, +): TabEntry[] { + return tabs + .slice() + .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)) + .flatMap((tab) => { + const content = tab.render(selection); + if (!content) return []; + return [ + { + id: tab.id, + name: tab.name, + content: content.content, + isLoading: content.isLoading, + buttons: content.buttons, + }, + ]; + }); +} + +function renderTabbedDetails( + trace: TraceImpl, + title: string, + tabs: ReadonlyArray, +) { + if (tabs.length === 0) { + return undefined; + } + + // Find the active tab or just pick the first one if that selected tab is + // not available. + const activeTab = + tabs.find((tab) => tab.id === trace.selection.currentSelectionSubTab) ?? + tabs[0]; + + // Determine if any tab content is loading + const isLoading = tabs.some((tab) => tab.isLoading); + + return { + isLoading, + content: m( + DetailsShell, + { + title, + description: m( + ButtonBar, + tabs.map((tab) => + m(Button, { + label: tab.name, + key: tab.id, + active: activeTab === tab, + onclick: () => trace.selection.setCurrentSelectionSubTab(tab.id), + }), + ), + ), + buttons: activeTab.buttons, + }, + // Render all tabs but control visibility with Gate + tabs.map((tab) => m(Gate, {open: activeTab === tab}, tab.content)), + ), + }; +} + export interface CurrentSelectionTabAttrs { readonly trace: TraceImpl; } @@ -56,7 +132,7 @@ export class CurrentSelectionTab implements m.ClassComponent (b.priority ?? 0) - (a.priority ?? 0), + const tabs = renderTabs(trace.selection.selectionTabs, selection); + return ( + renderTabbedDetails(trace, 'Area Selection', tabs) ?? + this.renderEmptySelection('No details available for selection') ); - - const renderedTabs = tabs - .map((tab) => [tab, tab.render(selection)] as const) - .filter(([_, content]) => content !== undefined); - - if (renderedTabs.length === 0) { - return this.renderEmptySelection('No details available for selection'); - } - - // Find the active tab or just pick the first one if that selected tab is - // not available. - const [activeTab, activeTabContent] = - renderedTabs.find( - ([tab]) => tab.id === trace.selection.currentSelectionSubTab, - ) ?? renderedTabs[0]; - - // Determine if any tab content is loading - const isLoading = renderedTabs.some(([_, content]) => content?.isLoading); - - return { - isLoading, - content: m( - DetailsShell, - { - title: 'Area Selection', - description: m( - ButtonBar, - renderedTabs.map(([tab]) => { - return m(Button, { - label: tab.name, - key: tab.id, - active: activeTab === tab, - onclick: () => - trace.selection.setCurrentSelectionSubTab(tab.id), - }); - }), - ), - buttons: activeTabContent?.buttons, - }, - // Render all tabs but control visibility with Gate - renderedTabs.map(([tab, content]) => - m(Gate, {open: activeTab === tab}, content?.content), - ), - ), - }; } private renderNoteSelection(trace: TraceImpl, selection: NoteSelection) { diff --git a/ui/src/public/selection.ts b/ui/src/public/selection.ts index f3485d83f83..b8c2a439826 100644 --- a/ui/src/public/selection.ts +++ b/ui/src/public/selection.ts @@ -35,7 +35,7 @@ export interface ContentWithLoadingFlag { readonly buttons?: m.Children; } -export interface AreaSelectionTab { +export interface SelectionTab { // Unique id for this tab. readonly id: string; @@ -54,13 +54,16 @@ export interface AreaSelectionTab { * has nothing relevant to show. * * The |isLoading| flag is used to avoid flickering. If set to true, we keep - * hold of the the previous vnodes, rendering them instead, for up to 50ms + * hold of the previous vnodes, rendering them instead, for up to 50ms * before switching to the new content. This avoids very fast load times * from causing flickering loading screens, which can be somewhat jarring. */ - render(selection: AreaSelection): ContentWithLoadingFlag | undefined; + render(selection: T): ContentWithLoadingFlag | undefined; } +export type AreaSelectionTab = SelectionTab; +export type TrackEventSelectionTab = SelectionTab; + /** * Compare two area selections for equality. Returns true if the selections are * equivalent, false otherwise. @@ -78,9 +81,9 @@ export interface SelectionManager { readonly selection: Selection; /** - * Provides a list of registered area selection tabs. + * Provides a list of registered selection tabs. */ - readonly areaSelectionTabs: ReadonlyArray; + readonly selectionTabs: ReadonlyArray; /** * Clears the current selection, selects nothing. @@ -154,10 +157,20 @@ export interface SelectionManager { */ getTimeSpanOfSelection(): TimeSpan | undefined; + /** + * Register a new tab under the selection details panel. + */ + registerSelectionTab(tab: SelectionTab): void; + /** * Register a new tab under the area selection details panel. */ registerAreaSelectionTab(tab: AreaSelectionTab): void; + + /** + * Register a new tab under the track event selection details panel. + */ + registerTrackEventSelectionTab(tab: TrackEventSelectionTab): void; } export type Selection =