Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 30 additions & 9 deletions docs/contributing/ui-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 24 additions & 2 deletions ui/src/core/selection_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import type {
SelectionManager,
TrackEventSelection,
AreaSelectionTab,
TrackEventSelectionTab,
SelectionTab,
} from '../public/selection';
import {TimeSpan} from '../base/time';
import {raf} from './raf_scheduler';
Expand Down Expand Up @@ -58,7 +60,7 @@ export class SelectionManagerImpl implements SelectionManager {
Selection,
SelectionDetailsPanel
>();
public readonly areaSelectionTabs: AreaSelectionTab[] = [];
public readonly selectionTabs: SelectionTab[] = [];
private _currentSelectionSubTab?: string;

constructor(
Expand Down Expand Up @@ -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 {
Expand Down
161 changes: 106 additions & 55 deletions ui/src/core_plugins/dev.perfetto.Timeline/current_selection_tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,89 @@ import {Tree, TreeNode} from '../../widgets/tree';
import type {
AreaSelection,
NoteSelection,
Selection,
SelectionTab,
TrackEventSelection,
TrackSelection,
} from '../../public/selection';
import {assertUnreachable} from '../../base/assert';
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<SelectionTab>,
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<TabEntry>,
) {
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;
}
Expand All @@ -56,7 +132,7 @@ export class CurrentSelectionTab implements m.ClassComponent<CurrentSelectionTab
case 'track':
return this.renderTrackSelection(trace, selection);
case 'track_event':
return this.renderTrackEventSelection(trace);
return this.renderTrackEventSelection(trace, selection);
case 'area':
return this.renderAreaSelection(trace, selection);
case 'note':
Expand All @@ -83,72 +159,47 @@ export class CurrentSelectionTab implements m.ClassComponent<CurrentSelectionTab
};
}

private renderTrackEventSelection(trace: TraceImpl) {
private renderTrackEventSelection(
trace: TraceImpl,
selection: TrackEventSelection,
) {
// The selection panel has already loaded the details panel for us... let's
// hope it's the right one!
const detailsPanel = trace.selection.getDetailsPanelForSelection();
if (detailsPanel) {
return {
isLoading: detailsPanel.isLoading,
content: detailsPanel.render(),
};
} else {
const extraTabs = renderTabs(trace.selection.selectionTabs, selection);

if (extraTabs.length === 0) {
if (detailsPanel) {
return {
isLoading: detailsPanel.isLoading,
content: detailsPanel.render(),
};
}
return {
isLoading: true,
content: 'Loading...',
};
}

const allTabs: TabEntry[] = [
{
id: 'overview',
name: 'Details',
content: detailsPanel ? detailsPanel.render() : 'Loading...',
isLoading: detailsPanel ? detailsPanel.isLoading : true,
},
...extraTabs,
];

return renderTabbedDetails(trace, 'Selection', allTabs)!;
}

private renderAreaSelection(trace: TraceImpl, selection: AreaSelection) {
const tabs = trace.selection.areaSelectionTabs.sort(
(a, b) => (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) {
Expand Down
23 changes: 18 additions & 5 deletions ui/src/public/selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export interface ContentWithLoadingFlag {
readonly buttons?: m.Children;
}

export interface AreaSelectionTab {
export interface SelectionTab<T = Selection> {
// Unique id for this tab.
readonly id: string;

Expand All @@ -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<AreaSelection>;
export type TrackEventSelectionTab = SelectionTab<TrackEventSelection>;

/**
* Compare two area selections for equality. Returns true if the selections are
* equivalent, false otherwise.
Expand All @@ -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<AreaSelectionTab>;
readonly selectionTabs: ReadonlyArray<SelectionTab>;

/**
* Clears the current selection, selects nothing.
Expand Down Expand Up @@ -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 =
Expand Down
Loading