1// Copyright (C) 2022 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15import m from 'mithril'; 16import {v4 as uuidv4} from 'uuid'; 17 18import {Actions} from '../common/actions'; 19import {EngineProxy} from '../common/engine'; 20import {Registry} from '../common/registry'; 21import {globals} from './globals'; 22 23import {Panel, PanelSize, PanelVNode} from './panel'; 24 25export interface NewBottomTabArgs { 26 engine: EngineProxy; 27 tag?: string; 28 uuid: string; 29 config: {}; 30} 31 32// Interface for allowing registration and creation of bottom tabs. 33// See comments on |TrackCreator| for more details. 34export interface BottomTabCreator { 35 readonly kind: string; 36 37 create(args: NewBottomTabArgs): BottomTab; 38} 39 40export const bottomTabRegistry = Registry.kindRegistry<BottomTabCreator>(); 41 42// Period to wait for the newly-added tabs which are loading before showing 43// them to the user. This period is short enough to not be user-visible, 44// while being long enough for most of the simple queries to complete, reducing 45// flickering in the UI. 46const NEW_LOADING_TAB_DELAY_MS = 50; 47 48// An interface representing a bottom tab displayed on the panel in the bottom 49// of the ui (e.g. "Current Selection"). 50// 51// The implementations of this class are provided by different plugins, which 52// register the implementations with bottomTabRegistry, keyed by a unique name 53// for each type of BottomTab. 54// 55// Lifetime: the instances of this class are owned by BottomTabPanel and exist 56// for as long as a tab header is shown to the user in the bottom tab list (with 57// minor exceptions, like a small grace period between when the tab is related). 58// 59// BottomTab implementations should pass the unique identifier(s) for the 60// content displayed via the |Config| and fetch additional details via Engine 61// instead of relying on getting the data from the global storage. For example, 62// for tabs corresponding to details of the selected objects on a track, a new 63// BottomTab should be created for each new selection. 64export abstract class BottomTabBase<Config = {}> { 65 // Config for this details panel. Should be serializable. 66 protected readonly config: Config; 67 // Engine for running queries and fetching additional data. 68 protected readonly engine: EngineProxy; 69 // Optional tag, which is used to ensure that only one tab 70 // with the same tag can exist - adding a new tab with the same tag 71 // (e.g. 'current_selection') would close the previous one. This 72 // also can be used to close existing tab. 73 readonly tag?: string; 74 // Unique id for this details panel. Can be used to close previously opened 75 // panel. 76 readonly uuid: string; 77 78 constructor(args: NewBottomTabArgs) { 79 this.config = args.config as Config; 80 this.engine = args.engine; 81 this.tag = args.tag; 82 this.uuid = args.uuid; 83 } 84 85 // Entry point for customisation of the displayed title for this panel. 86 abstract getTitle(): string; 87 88 // Generate a mithril node for this component. 89 abstract createPanelVnode(): PanelVNode; 90 91 // API for the tab to notify the TabList that it's still preparing the data. 92 // If true, adding a new tab will be delayed for a short while (~50ms) to 93 // reduce the flickering. 94 // 95 // Note: it's a "poll" rather than "push" API: there is no explicit API 96 // for the tabs to notify the tab list, as the tabs are expected to schedule 97 // global redraw anyway and the tab list will poll the tabs as necessary 98 // during the redraw. 99 isLoading(): boolean { 100 return false; 101 } 102} 103 104 105// BottomTabBase provides a more generic API allowing users to provide their 106// custom mithril component, which would allow them to listen to mithril 107// lifecycle events. Most cases, however, don't need them and BottomTab 108// provides a simplified API for the common case. 109export abstract class BottomTab<Config = {}> extends BottomTabBase<Config> { 110 constructor(args: NewBottomTabArgs) { 111 super(args); 112 } 113 114 // These methods are direct counterparts to renderCanvas and view with 115 // slightly changes names to prevent cases when `BottomTab` will 116 // be accidentally used a mithril component. 117 abstract renderTabCanvas(ctx: CanvasRenderingContext2D, size: PanelSize): 118 void; 119 abstract viewTab(): void|m.Children; 120 121 createPanelVnode(): m.Vnode<any, any> { 122 return m( 123 BottomTabAdapter, 124 {key: this.uuid, panel: this} as BottomTabAdapterAttrs); 125 } 126} 127 128interface BottomTabAdapterAttrs { 129 panel: BottomTab; 130} 131 132class BottomTabAdapter extends Panel<BottomTabAdapterAttrs> { 133 renderCanvas( 134 ctx: CanvasRenderingContext2D, size: PanelSize, 135 vnode: PanelVNode<BottomTabAdapterAttrs>): void { 136 vnode.attrs.panel.renderTabCanvas(ctx, size); 137 } 138 139 view(vnode: m.CVnode<BottomTabAdapterAttrs>): void|m.Children { 140 return vnode.attrs.panel.viewTab(); 141 } 142} 143 144export type AddTabArgs = { 145 kind: string, 146 config: {}, 147 tag?: string, 148 // Whether to make the new tab current. True by default. 149 select?: boolean; 150}; 151 152export type AddTabResult = 153 { 154 uuid: string; 155 } 156 157// Shorthand for globals.bottomTabList.addTab(...) & redraw. 158// Ignored when bottomTabList does not exist (e.g. no trace is open in the UI). 159export function 160addTab(args: AddTabArgs) { 161 const tabList = globals.bottomTabList; 162 if (!tabList) { 163 return; 164 } 165 tabList.addTab(args); 166 globals.rafScheduler.scheduleFullRedraw(); 167} 168 169 170// Shorthand for globals.bottomTabList.closeTabById(...) & redraw. 171// Ignored when bottomTabList does not exist (e.g. no trace is open in the UI). 172export function 173closeTab(uuid: string) { 174 const tabList = globals.bottomTabList; 175 if (!tabList) { 176 return; 177 } 178 tabList.closeTabById(uuid); 179 globals.rafScheduler.scheduleFullRedraw(); 180} 181 182interface PendingTab { 183 tab: BottomTabBase, args: AddTabArgs, startTime: number, 184} 185 186function tabSelectionKey(tab: BottomTabBase) { 187 return tab.tag ?? tab.uuid; 188} 189 190export class BottomTabList { 191 private tabs: BottomTabBase[] = []; 192 private pendingTabs: PendingTab[] = []; 193 private engine: EngineProxy; 194 private scheduledFlushSetTimeoutId?: number; 195 196 constructor(engine: EngineProxy) { 197 this.engine = engine; 198 } 199 200 getTabs(): BottomTabBase[] { 201 this.flushPendingTabs(); 202 return this.tabs; 203 } 204 205 // Add and create a new panel with given kind and config, replacing an 206 // existing panel with the same tag if needed. Returns the uuid of a newly 207 // created panel (which can be used in the future to close it). 208 addTab(args: AddTabArgs): AddTabResult { 209 const uuid = uuidv4(); 210 const newPanel = bottomTabRegistry.get(args.kind).create({ 211 engine: this.engine, 212 uuid, 213 config: args.config, 214 tag: args.tag, 215 }); 216 217 this.pendingTabs.push({ 218 tab: newPanel, 219 args, 220 startTime: window.performance.now(), 221 }); 222 this.flushPendingTabs(); 223 224 return { 225 uuid, 226 }; 227 } 228 229 closeTabByTag(tag: string) { 230 const index = this.tabs.findIndex((tab) => tab.tag === tag); 231 if (index !== -1) { 232 this.removeTabAtIndex(index); 233 } 234 // User closing a tab by tag should affect pending tabs as well, as these 235 // tabs were requested to be added to the tab list before this call. 236 this.pendingTabs = this.pendingTabs.filter(({tab}) => tab.tag !== tag); 237 } 238 239 closeTabById(uuid: string) { 240 const index = this.tabs.findIndex((tab) => tab.uuid === uuid); 241 if (index !== -1) { 242 this.removeTabAtIndex(index); 243 } 244 // User closing a tab by id should affect pending tabs as well, as these 245 // tabs were requested to be added to the tab list before this call. 246 this.pendingTabs = this.pendingTabs.filter(({tab}) => tab.uuid !== uuid); 247 } 248 249 private removeTabAtIndex(index: number) { 250 const tab = this.tabs[index]; 251 this.tabs.splice(index, 1); 252 // If the current tab was closed, select the tab to the right of it. 253 // If the closed tab was current and last in the tab list, select the tab 254 // that became last. 255 if (tab.uuid === globals.state.currentTab && this.tabs.length > 0) { 256 const newActiveIndex = index === this.tabs.length ? index - 1 : index; 257 globals.dispatch(Actions.setCurrentTab( 258 {tab: tabSelectionKey(this.tabs[newActiveIndex])})); 259 } 260 globals.rafScheduler.scheduleFullRedraw(); 261 } 262 263 // Check the list of the pending tabs and add the ones that are ready 264 // (either tab.isLoading returns false or NEW_LOADING_TAB_DELAY_MS ms elapsed 265 // since this tab was added). 266 // Note: the pending tabs are stored in a queue to preserve the action order, 267 // which matters for cases like adding tabs with the same tag. 268 private flushPendingTabs() { 269 const currentTime = window.performance.now(); 270 while (this.pendingTabs.length > 0) { 271 const {tab, args, startTime} = this.pendingTabs[0]; 272 273 // This is a dirty hack^W^W low-lift solution for the world where some 274 // "current selection" panels are implemented by BottomTabs and some by 275 // details_panel.ts computing vnodes dynamically. Naive implementation 276 // will: a) stop showing the old panel (because 277 // globals.state.currentSelection changes). b) not showing the new 278 // 'current_selection' tab yet. This will result in temporary shifting 279 // focus to another tab (as no tab with 'current_selection' tag will 280 // exist). 281 // 282 // To counteract this, short-circuit this logic and when: 283 // a) no tag with 'current_selection' tag exists in the list of currently 284 // displayed tabs and b) we are adding a tab with 'current_selection' tag. 285 // add it immediately without waiting. 286 // TODO(altimin): Remove this once all places have switched to be using 287 // BottomTab to display panels. 288 const currentSelectionTabAlreadyExists = 289 this.tabs.filter((tab) => tab.tag === 'current_selection').length > 0; 290 const dirtyHackForCurrentSelectionApplies = 291 tab.tag === 'current_selection' && !currentSelectionTabAlreadyExists; 292 293 const elapsedTimeMs = currentTime - startTime; 294 if (tab.isLoading() && elapsedTimeMs < NEW_LOADING_TAB_DELAY_MS && 295 !dirtyHackForCurrentSelectionApplies) { 296 this.schedulePendingTabsFlush(NEW_LOADING_TAB_DELAY_MS - elapsedTimeMs); 297 // The first tab is not ready yet, wait. 298 return; 299 } 300 this.pendingTabs.shift(); 301 302 const index = 303 args.tag ? this.tabs.findIndex((tab) => tab.tag === args.tag) : -1; 304 if (index === -1) { 305 this.tabs.push(tab); 306 } else { 307 this.tabs[index] = tab; 308 } 309 310 if (args.select === undefined || args.select === true) { 311 globals.dispatch(Actions.setCurrentTab({tab: tabSelectionKey(tab)})); 312 } 313 } 314 } 315 316 private schedulePendingTabsFlush(waitTimeMs: number) { 317 if (this.scheduledFlushSetTimeoutId) { 318 // The flush is already pending, no action is required. 319 return; 320 } 321 setTimeout(() => { 322 this.scheduledFlushSetTimeoutId = undefined; 323 this.flushPendingTabs(); 324 }, waitTimeMs); 325 } 326} 327