| // 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 m from 'mithril'; |
| import {Gate} from '../base/mithril_utils'; |
| import {EmptyState} from '../widgets/empty_state'; |
| import {raf} from '../core/raf_scheduler'; |
| import {DetailsShell} from '../widgets/details_shell'; |
| import {GridLayout, GridLayoutColumn} from '../widgets/grid_layout'; |
| import {Section} from '../widgets/section'; |
| import {Tree, TreeNode} from '../widgets/tree'; |
| import {TraceImpl, TraceImplAttrs} from '../core/trace_impl'; |
| import {MenuItem, PopupMenu2} from '../widgets/menu'; |
| import {Button} from '../widgets/button'; |
| import {DEFAULT_DETAILS_CONTENT_HEIGHT} from './css_constants'; |
| import {DisposableStack} from '../base/disposable_stack'; |
| import {DragGestureHandler} from '../base/drag_gesture_handler'; |
| import {assertExists} from '../base/logging'; |
| |
| export type TabPanelAttrs = TraceImplAttrs; |
| |
| export interface Tab { |
| // Unique key for this tab, passed to callbacks. |
| key: string; |
| |
| // Tab title to show on the tab handle. |
| title: m.Children; |
| |
| // Whether to show a close button on the tab handle or not. |
| // Default = false. |
| hasCloseButton?: boolean; |
| } |
| |
| interface TabWithContent extends Tab { |
| content: m.Children; |
| } |
| |
| export interface TabDropdownEntry { |
| // Unique key for this tab dropdown entry. |
| key: string; |
| |
| // Title to show on this entry. |
| title: string; |
| |
| // Called when tab dropdown entry is clicked. |
| onClick: () => void; |
| |
| // Whether this tab is checked or not |
| checked: boolean; |
| } |
| |
| export class TabPanel implements m.ClassComponent<TabPanelAttrs> { |
| private readonly trace: TraceImpl; |
| // Tabs panel starts collapsed. |
| |
| // NOTE: the visibility state of the tab panel (COLLAPSED, VISIBLE, |
| // FULLSCREEN) is stored in TabManagerImpl because it can be toggled via |
| // commands. Here we store only the heights for the various states, because |
| // nobody else needs to know about them and are an impl. detail of the VDOM. |
| |
| // The actual height of the vdom node. It matches resizableHeight if VISIBLE, |
| // 0 if COLLAPSED, fullscreenHeight if FULLSCREEN. |
| private height = 0; |
| |
| // The height when the panel is 'VISIBLE'. |
| private resizableHeight = getDefaultDetailsHeight(); |
| |
| // The height when the panel is 'FULLSCREEN'. |
| private fullscreenHeight = 0; |
| |
| private fadeContext = new FadeContext(); |
| private trash = new DisposableStack(); |
| |
| constructor({attrs}: m.CVnode<TabPanelAttrs>) { |
| this.trace = attrs.trace; |
| } |
| |
| view() { |
| const tabMan = this.trace.tabs; |
| const tabList = this.trace.tabs.openTabsUri; |
| const resolvedTabs = tabMan.resolveTabs(tabList); |
| |
| switch (this.trace.tabs.tabPanelVisibility) { |
| case 'VISIBLE': |
| this.height = Math.min( |
| Math.max(this.resizableHeight, 0), |
| this.fullscreenHeight, |
| ); |
| break; |
| case 'FULLSCREEN': |
| this.height = this.fullscreenHeight; |
| break; |
| case 'COLLAPSED': |
| this.height = 0; |
| break; |
| } |
| |
| const tabs = resolvedTabs.map(({uri, tab: tabDesc}): TabWithContent => { |
| if (tabDesc) { |
| return { |
| key: uri, |
| hasCloseButton: true, |
| title: tabDesc.content.getTitle(), |
| content: tabDesc.content.render(), |
| }; |
| } else { |
| return { |
| key: uri, |
| hasCloseButton: true, |
| title: 'Tab does not exist', |
| content: undefined, |
| }; |
| } |
| }); |
| |
| // Add the permanent current selection tab to the front of the list of tabs |
| tabs.unshift({ |
| key: 'current_selection', |
| title: 'Current Selection', |
| content: this.renderCSTabContentWithFading(), |
| }); |
| |
| return [ |
| // Render the header with the ... menu, tab strip and resize buttons. |
| m( |
| '.handle', |
| this.renderTripleDotDropdownMenu(), |
| this.renderTabStrip(tabs), |
| this.renderTabResizeButtons(), |
| ), |
| // Render the tab contents. |
| m( |
| '.details-panel-container', |
| { |
| style: {height: `${this.height}px`}, |
| }, |
| tabs.map(({key, content}) => { |
| const active = key === this.trace.tabs.currentTabUri; |
| return m(Gate, {open: active}, content); |
| }), |
| ), |
| ]; |
| } |
| |
| oncreate(vnode: m.VnodeDOM<TraceImplAttrs, this>) { |
| let dragStartY = 0; |
| let heightWhenDragStarted = 0; |
| |
| this.trash.use( |
| new DragGestureHandler( |
| vnode.dom as HTMLElement, |
| /* onDrag */ (_x, y) => { |
| const deltaYSinceDragStart = dragStartY - y; |
| this.resizableHeight = heightWhenDragStarted + deltaYSinceDragStart; |
| raf.scheduleFullRedraw('force'); |
| }, |
| /* onDragStarted */ (_x, y) => { |
| this.resizableHeight = this.height; |
| heightWhenDragStarted = this.height; |
| dragStartY = y; |
| this.trace.tabs.setTabPanelVisibility('VISIBLE'); |
| }, |
| /* onDragFinished */ () => {}, |
| ), |
| ); |
| |
| const page = assertExists(vnode.dom.parentElement); |
| this.fullscreenHeight = page.clientHeight; |
| const resizeObs = new ResizeObserver(() => { |
| this.fullscreenHeight = page.clientHeight; |
| raf.scheduleFullRedraw(); |
| }); |
| resizeObs.observe(page); |
| this.trash.defer(() => resizeObs.disconnect()); |
| } |
| |
| onremove() { |
| this.trash.dispose(); |
| } |
| |
| private renderTripleDotDropdownMenu(): m.Child { |
| const entries = this.trace.tabs.tabs |
| .filter((tab) => tab.isEphemeral === false) |
| .map(({content, uri}): TabDropdownEntry => { |
| return { |
| key: uri, |
| title: content.getTitle(), |
| onClick: () => this.trace.tabs.toggleTab(uri), |
| checked: this.trace.tabs.isOpen(uri), |
| }; |
| }); |
| |
| return m( |
| '.buttons', |
| m( |
| PopupMenu2, |
| { |
| trigger: m(Button, { |
| compact: true, |
| icon: 'more_vert', |
| disabled: entries.length === 0, |
| title: 'More Tabs', |
| }), |
| }, |
| entries.map((entry) => { |
| return m(MenuItem, { |
| key: entry.key, |
| label: entry.title, |
| onclick: () => entry.onClick(), |
| icon: entry.checked ? 'check_box' : 'check_box_outline_blank', |
| }); |
| }), |
| ), |
| ); |
| } |
| |
| private renderTabStrip(tabs: Tab[]): m.Child { |
| const currentTabKey = this.trace.tabs.currentTabUri; |
| return m( |
| '.tabs', |
| tabs.map((tab) => { |
| const {key, hasCloseButton = false} = tab; |
| const tag = currentTabKey === key ? '.tab[active]' : '.tab'; |
| return m( |
| tag, |
| { |
| key, |
| onclick: (event: Event) => { |
| if (!event.defaultPrevented) { |
| this.trace.tabs.showTab(key); |
| } |
| }, |
| // Middle click to close |
| onauxclick: (event: MouseEvent) => { |
| if (!event.defaultPrevented) { |
| this.trace.tabs.hideTab(key); |
| } |
| }, |
| }, |
| m('span.pf-tab-title', tab.title), |
| hasCloseButton && |
| m(Button, { |
| onclick: (event: Event) => { |
| this.trace.tabs.hideTab(key); |
| event.preventDefault(); |
| }, |
| compact: true, |
| icon: 'close', |
| }), |
| ); |
| }), |
| ); |
| } |
| |
| private renderTabResizeButtons(): m.Child { |
| const isClosed = this.trace.tabs.tabPanelVisibility === 'COLLAPSED'; |
| return m( |
| '.buttons', |
| m(Button, { |
| title: 'Open fullscreen', |
| disabled: this.trace.tabs.tabPanelVisibility === 'FULLSCREEN', |
| icon: 'vertical_align_top', |
| compact: true, |
| onclick: () => this.trace.tabs.setTabPanelVisibility('FULLSCREEN'), |
| }), |
| m(Button, { |
| onclick: () => this.trace.tabs.toggleTabPanelVisibility(), |
| title: isClosed ? 'Show panel' : 'Hide panel', |
| icon: isClosed ? 'keyboard_arrow_up' : 'keyboard_arrow_down', |
| compact: true, |
| }), |
| ); |
| } |
| |
| private renderCSTabContentWithFading(): m.Children { |
| const section = this.renderCSTabContent(); |
| if (section.isLoading) { |
| return m(FadeIn, section.content); |
| } else { |
| return m(FadeOut, {context: this.fadeContext}, section.content); |
| } |
| } |
| |
| private renderCSTabContent(): {isLoading: boolean; content: m.Children} { |
| const currentSelection = this.trace.selection.selection; |
| if (currentSelection.kind === 'empty') { |
| return { |
| isLoading: false, |
| content: m( |
| EmptyState, |
| { |
| className: 'pf-noselection', |
| title: 'Nothing selected', |
| }, |
| 'Selection details will appear here', |
| ), |
| }; |
| } |
| |
| if (currentSelection.kind === 'track') { |
| return { |
| isLoading: false, |
| content: this.renderTrackDetailsPanel(currentSelection.trackUri), |
| }; |
| } |
| |
| const detailsPanel = this.trace.selection.getDetailsPanelForSelection(); |
| if (currentSelection.kind === 'track_event' && detailsPanel !== undefined) { |
| return { |
| isLoading: detailsPanel.isLoading, |
| content: detailsPanel.render(), |
| }; |
| } |
| |
| // Get the first "truthy" details panel |
| const detailsPanels = this.trace.tabs.detailsPanels.map((dp) => { |
| return { |
| content: dp.render(currentSelection), |
| isLoading: dp.isLoading?.() ?? false, |
| }; |
| }); |
| |
| const panel = detailsPanels.find(({content}) => content); |
| |
| if (panel) { |
| return panel; |
| } else { |
| return { |
| isLoading: false, |
| content: m( |
| EmptyState, |
| { |
| className: 'pf-noselection', |
| title: 'No details available', |
| icon: 'warning', |
| }, |
| `Selection kind: '${currentSelection.kind}'`, |
| ), |
| }; |
| } |
| } |
| |
| private renderTrackDetailsPanel(trackUri: string) { |
| const track = this.trace.tracks.getTrack(trackUri); |
| if (track) { |
| return m( |
| DetailsShell, |
| {title: 'Track', description: track.title}, |
| m( |
| GridLayout, |
| m( |
| GridLayoutColumn, |
| m( |
| Section, |
| {title: 'Details'}, |
| m( |
| Tree, |
| m(TreeNode, {left: 'Name', right: track.title}), |
| m(TreeNode, {left: 'URI', right: track.uri}), |
| m(TreeNode, {left: 'Plugin ID', right: track.pluginId}), |
| m( |
| TreeNode, |
| {left: 'Tags'}, |
| track.tags && |
| Object.entries(track.tags).map(([key, value]) => { |
| return m(TreeNode, {left: key, right: value?.toString()}); |
| }), |
| ), |
| ), |
| ), |
| ), |
| ), |
| ); |
| } else { |
| return undefined; // TODO show something sensible here |
| } |
| } |
| } |
| |
| const FADE_TIME_MS = 50; |
| |
| class FadeContext { |
| private resolver = () => {}; |
| |
| putResolver(res: () => void) { |
| this.resolver = res; |
| } |
| |
| resolve() { |
| this.resolver(); |
| this.resolver = () => {}; |
| } |
| } |
| |
| interface FadeOutAttrs { |
| context: FadeContext; |
| } |
| |
| class FadeOut implements m.ClassComponent<FadeOutAttrs> { |
| onbeforeremove({attrs}: m.VnodeDOM<FadeOutAttrs>): Promise<void> { |
| return new Promise((res) => { |
| attrs.context.putResolver(res); |
| setTimeout(res, FADE_TIME_MS); |
| }); |
| } |
| |
| oncreate({attrs}: m.VnodeDOM<FadeOutAttrs>) { |
| attrs.context.resolve(); |
| } |
| |
| view(vnode: m.Vnode<FadeOutAttrs>): void | m.Children { |
| return vnode.children; |
| } |
| } |
| |
| class FadeIn implements m.ClassComponent { |
| private show = false; |
| |
| oncreate(_: m.VnodeDOM) { |
| setTimeout(() => { |
| this.show = true; |
| raf.scheduleFullRedraw(); |
| }, FADE_TIME_MS); |
| } |
| |
| view(vnode: m.Vnode): m.Children { |
| return this.show ? vnode.children : undefined; |
| } |
| } |
| |
| function getDefaultDetailsHeight() { |
| const DRAG_HANDLE_HEIGHT_PX = 28; |
| // This needs to be a function instead of a const to ensure the CSS constants |
| // have been initialized by the time we perform this calculation; |
| return DRAG_HANDLE_HEIGHT_PX + DEFAULT_DETAILS_CONTENT_HEIGHT; |
| } |