blob: f78bd0c2c96d58943e588ae6851fb2afff33b8d0 [file] [log] [blame]
// Copyright (C) 2022 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 m from 'mithril';
import {v4 as uuidv4} from 'uuid';
import {Actions} from '../common/actions';
import {EngineProxy} from '../common/engine';
import {Registry} from '../common/registry';
import {globals} from './globals';
import {Panel, PanelSize, PanelVNode} from './panel';
export interface NewBottomTabArgs {
engine: EngineProxy;
tag?: string;
uuid: string;
config: {};
}
// Interface for allowing registration and creation of bottom tabs.
// See comments on |TrackCreator| for more details.
export interface BottomTabCreator {
readonly kind: string;
create(args: NewBottomTabArgs): BottomTab;
}
export const bottomTabRegistry = Registry.kindRegistry<BottomTabCreator>();
// Period to wait for the newly-added tabs which are loading before showing
// them to the user. This period is short enough to not be user-visible,
// while being long enough for most of the simple queries to complete, reducing
// flickering in the UI.
const NEW_LOADING_TAB_DELAY_MS = 50;
// An interface representing a bottom tab displayed on the panel in the bottom
// of the ui (e.g. "Current Selection").
//
// The implementations of this class are provided by different plugins, which
// register the implementations with bottomTabRegistry, keyed by a unique name
// for each type of BottomTab.
//
// Lifetime: the instances of this class are owned by BottomTabPanel and exist
// for as long as a tab header is shown to the user in the bottom tab list (with
// minor exceptions, like a small grace period between when the tab is related).
//
// BottomTab implementations should pass the unique identifier(s) for the
// content displayed via the |Config| and fetch additional details via Engine
// instead of relying on getting the data from the global storage. For example,
// for tabs corresponding to details of the selected objects on a track, a new
// BottomTab should be created for each new selection.
export abstract class BottomTabBase<Config = {}> {
// Config for this details panel. Should be serializable.
protected readonly config: Config;
// Engine for running queries and fetching additional data.
protected readonly engine: EngineProxy;
// Optional tag, which is used to ensure that only one tab
// with the same tag can exist - adding a new tab with the same tag
// (e.g. 'current_selection') would close the previous one. This
// also can be used to close existing tab.
readonly tag?: string;
// Unique id for this details panel. Can be used to close previously opened
// panel.
readonly uuid: string;
constructor(args: NewBottomTabArgs) {
this.config = args.config as Config;
this.engine = args.engine;
this.tag = args.tag;
this.uuid = args.uuid;
}
// Entry point for customisation of the displayed title for this panel.
abstract getTitle(): string;
// Generate a mithril node for this component.
abstract createPanelVnode(): PanelVNode;
// API for the tab to notify the TabList that it's still preparing the data.
// If true, adding a new tab will be delayed for a short while (~50ms) to
// reduce the flickering.
//
// Note: it's a "poll" rather than "push" API: there is no explicit API
// for the tabs to notify the tab list, as the tabs are expected to schedule
// global redraw anyway and the tab list will poll the tabs as necessary
// during the redraw.
isLoading(): boolean {
return false;
}
}
// BottomTabBase provides a more generic API allowing users to provide their
// custom mithril component, which would allow them to listen to mithril
// lifecycle events. Most cases, however, don't need them and BottomTab
// provides a simplified API for the common case.
export abstract class BottomTab<Config = {}> extends BottomTabBase<Config> {
constructor(args: NewBottomTabArgs) {
super(args);
}
// These methods are direct counterparts to renderCanvas and view with
// slightly changes names to prevent cases when `BottomTab` will
// be accidentally used a mithril component.
abstract renderTabCanvas(ctx: CanvasRenderingContext2D, size: PanelSize):
void;
abstract viewTab(): void|m.Children;
createPanelVnode(): m.Vnode<any, any> {
return m(
BottomTabAdapter,
{key: this.uuid, panel: this} as BottomTabAdapterAttrs);
}
}
interface BottomTabAdapterAttrs {
panel: BottomTab;
}
class BottomTabAdapter extends Panel<BottomTabAdapterAttrs> {
renderCanvas(
ctx: CanvasRenderingContext2D, size: PanelSize,
vnode: PanelVNode<BottomTabAdapterAttrs>): void {
vnode.attrs.panel.renderTabCanvas(ctx, size);
}
view(vnode: m.CVnode<BottomTabAdapterAttrs>): void|m.Children {
return vnode.attrs.panel.viewTab();
}
}
export type AddTabArgs = {
kind: string,
config: {},
tag?: string,
// Whether to make the new tab current. True by default.
select?: boolean;
};
export type AddTabResult =
{
uuid: string;
}
// Shorthand for globals.bottomTabList.addTab(...) & redraw.
// Ignored when bottomTabList does not exist (e.g. no trace is open in the UI).
export function
addTab(args: AddTabArgs) {
const tabList = globals.bottomTabList;
if (!tabList) {
return;
}
tabList.addTab(args);
globals.rafScheduler.scheduleFullRedraw();
}
// Shorthand for globals.bottomTabList.closeTabById(...) & redraw.
// Ignored when bottomTabList does not exist (e.g. no trace is open in the UI).
export function
closeTab(uuid: string) {
const tabList = globals.bottomTabList;
if (!tabList) {
return;
}
tabList.closeTabById(uuid);
globals.rafScheduler.scheduleFullRedraw();
}
interface PendingTab {
tab: BottomTabBase, args: AddTabArgs, startTime: number,
}
function tabSelectionKey(tab: BottomTabBase) {
return tab.tag ?? tab.uuid;
}
export class BottomTabList {
private tabs: BottomTabBase[] = [];
private pendingTabs: PendingTab[] = [];
private engine: EngineProxy;
private scheduledFlushSetTimeoutId?: number;
constructor(engine: EngineProxy) {
this.engine = engine;
}
getTabs(): BottomTabBase[] {
this.flushPendingTabs();
return this.tabs;
}
// Add and create a new panel with given kind and config, replacing an
// existing panel with the same tag if needed. Returns the uuid of a newly
// created panel (which can be used in the future to close it).
addTab(args: AddTabArgs): AddTabResult {
const uuid = uuidv4();
const newPanel = bottomTabRegistry.get(args.kind).create({
engine: this.engine,
uuid,
config: args.config,
tag: args.tag,
});
this.pendingTabs.push({
tab: newPanel,
args,
startTime: window.performance.now(),
});
this.flushPendingTabs();
return {
uuid,
};
}
closeTabByTag(tag: string) {
const index = this.tabs.findIndex((tab) => tab.tag === tag);
if (index !== -1) {
this.removeTabAtIndex(index);
}
// User closing a tab by tag should affect pending tabs as well, as these
// tabs were requested to be added to the tab list before this call.
this.pendingTabs = this.pendingTabs.filter(({tab}) => tab.tag !== tag);
}
closeTabById(uuid: string) {
const index = this.tabs.findIndex((tab) => tab.uuid === uuid);
if (index !== -1) {
this.removeTabAtIndex(index);
}
// User closing a tab by id should affect pending tabs as well, as these
// tabs were requested to be added to the tab list before this call.
this.pendingTabs = this.pendingTabs.filter(({tab}) => tab.uuid !== uuid);
}
private removeTabAtIndex(index: number) {
const tab = this.tabs[index];
this.tabs.splice(index, 1);
// If the current tab was closed, select the tab to the right of it.
// If the closed tab was current and last in the tab list, select the tab
// that became last.
if (tab.uuid === globals.state.currentTab && this.tabs.length > 0) {
const newActiveIndex = index === this.tabs.length ? index - 1 : index;
globals.dispatch(Actions.setCurrentTab(
{tab: tabSelectionKey(this.tabs[newActiveIndex])}));
}
globals.rafScheduler.scheduleFullRedraw();
}
// Check the list of the pending tabs and add the ones that are ready
// (either tab.isLoading returns false or NEW_LOADING_TAB_DELAY_MS ms elapsed
// since this tab was added).
// Note: the pending tabs are stored in a queue to preserve the action order,
// which matters for cases like adding tabs with the same tag.
private flushPendingTabs() {
const currentTime = window.performance.now();
while (this.pendingTabs.length > 0) {
const {tab, args, startTime} = this.pendingTabs[0];
// This is a dirty hack^W^W low-lift solution for the world where some
// "current selection" panels are implemented by BottomTabs and some by
// details_panel.ts computing vnodes dynamically. Naive implementation
// will: a) stop showing the old panel (because
// globals.state.currentSelection changes). b) not showing the new
// 'current_selection' tab yet. This will result in temporary shifting
// focus to another tab (as no tab with 'current_selection' tag will
// exist).
//
// To counteract this, short-circuit this logic and when:
// a) no tag with 'current_selection' tag exists in the list of currently
// displayed tabs and b) we are adding a tab with 'current_selection' tag.
// add it immediately without waiting.
// TODO(altimin): Remove this once all places have switched to be using
// BottomTab to display panels.
const currentSelectionTabAlreadyExists =
this.tabs.filter((tab) => tab.tag === 'current_selection').length > 0;
const dirtyHackForCurrentSelectionApplies =
tab.tag === 'current_selection' && !currentSelectionTabAlreadyExists;
const elapsedTimeMs = currentTime - startTime;
if (tab.isLoading() && elapsedTimeMs < NEW_LOADING_TAB_DELAY_MS &&
!dirtyHackForCurrentSelectionApplies) {
this.schedulePendingTabsFlush(NEW_LOADING_TAB_DELAY_MS - elapsedTimeMs);
// The first tab is not ready yet, wait.
return;
}
this.pendingTabs.shift();
const index =
args.tag ? this.tabs.findIndex((tab) => tab.tag === args.tag) : -1;
if (index === -1) {
this.tabs.push(tab);
} else {
this.tabs[index] = tab;
}
if (args.select === undefined || args.select === true) {
globals.dispatch(Actions.setCurrentTab({tab: tabSelectionKey(tab)}));
}
}
}
private schedulePendingTabsFlush(waitTimeMs: number) {
if (this.scheduledFlushSetTimeoutId) {
// The flush is already pending, no action is required.
return;
}
setTimeout(() => {
this.scheduledFlushSetTimeoutId = undefined;
this.flushPendingTabs();
}, waitTimeMs);
}
}