blob: 04aee7ec09444d9c30813c021cc9f769c67b9278 [file] [log] [blame]
// 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;
}