xref: /aosp_15_r20/external/perfetto/ui/src/frontend/tab_panel.ts (revision 6dbdd20afdafa5e3ca9b8809fa73465d530080dc)
1// Copyright (C) 2024 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 {Gate} from '../base/mithril_utils';
17import {EmptyState} from '../widgets/empty_state';
18import {raf} from '../core/raf_scheduler';
19import {DetailsShell} from '../widgets/details_shell';
20import {GridLayout, GridLayoutColumn} from '../widgets/grid_layout';
21import {Section} from '../widgets/section';
22import {Tree, TreeNode} from '../widgets/tree';
23import {TraceImpl, TraceImplAttrs} from '../core/trace_impl';
24import {MenuItem, PopupMenu2} from '../widgets/menu';
25import {Button} from '../widgets/button';
26import {CollapsiblePanel} from '../components/widgets/collapsible_panel';
27
28export type TabPanelAttrs = TraceImplAttrs;
29
30export interface Tab {
31  // Unique key for this tab, passed to callbacks.
32  key: string;
33
34  // Tab title to show on the tab handle.
35  title: m.Children;
36
37  // Whether to show a close button on the tab handle or not.
38  // Default = false.
39  hasCloseButton?: boolean;
40}
41
42interface TabWithContent extends Tab {
43  content: m.Children;
44}
45
46export interface TabDropdownEntry {
47  // Unique key for this tab dropdown entry.
48  key: string;
49
50  // Title to show on this entry.
51  title: string;
52
53  // Called when tab dropdown entry is clicked.
54  onClick: () => void;
55
56  // Whether this tab is checked or not
57  checked: boolean;
58}
59
60export class TabPanel implements m.ClassComponent<TabPanelAttrs> {
61  private readonly trace: TraceImpl;
62  // Tabs panel starts collapsed.
63
64  private fadeContext = new FadeContext();
65
66  constructor({attrs}: m.CVnode<TabPanelAttrs>) {
67    this.trace = attrs.trace;
68  }
69
70  view() {
71    const tabMan = this.trace.tabs;
72    const tabList = this.trace.tabs.openTabsUri;
73    const resolvedTabs = tabMan.resolveTabs(tabList);
74
75    const tabs = resolvedTabs.map(({uri, tab: tabDesc}): TabWithContent => {
76      if (tabDesc) {
77        return {
78          key: uri,
79          hasCloseButton: true,
80          title: tabDesc.content.getTitle(),
81          content: tabDesc.content.render(),
82        };
83      } else {
84        return {
85          key: uri,
86          hasCloseButton: true,
87          title: 'Tab does not exist',
88          content: undefined,
89        };
90      }
91    });
92
93    // Add the permanent current selection tab to the front of the list of tabs
94    tabs.unshift({
95      key: 'current_selection',
96      title: 'Current Selection',
97      content: this.renderCSTabContentWithFading(),
98    });
99
100    return m(CollapsiblePanel, {
101      visibility: this.trace.tabs.tabPanelVisibility,
102      setVisibility: (visibility) =>
103        this.trace.tabs.setTabPanelVisibility(visibility),
104      headerActions: [
105        this.renderTripleDotDropdownMenu(),
106        this.renderTabStrip(tabs),
107      ],
108      tabs: tabs.map(({key, content}) => {
109        const active = key === this.trace.tabs.currentTabUri;
110        return m(Gate, {open: active}, content);
111      }),
112    });
113  }
114
115  private renderTripleDotDropdownMenu(): m.Child {
116    const entries = this.trace.tabs.tabs
117      .filter((tab) => tab.isEphemeral === false)
118      .map(({content, uri}): TabDropdownEntry => {
119        return {
120          key: uri,
121          title: content.getTitle(),
122          onClick: () => this.trace.tabs.toggleTab(uri),
123          checked: this.trace.tabs.isOpen(uri),
124        };
125      });
126
127    return m(
128      '.buttons',
129      m(
130        PopupMenu2,
131        {
132          trigger: m(Button, {
133            compact: true,
134            icon: 'more_vert',
135            disabled: entries.length === 0,
136            title: 'More Tabs',
137          }),
138        },
139        entries.map((entry) => {
140          return m(MenuItem, {
141            key: entry.key,
142            label: entry.title,
143            onclick: () => entry.onClick(),
144            icon: entry.checked ? 'check_box' : 'check_box_outline_blank',
145          });
146        }),
147      ),
148    );
149  }
150
151  private renderTabStrip(tabs: Tab[]): m.Child {
152    const currentTabKey = this.trace.tabs.currentTabUri;
153    return m(
154      '.tabs',
155      tabs.map((tab) => {
156        const {key, hasCloseButton = false} = tab;
157        const tag = currentTabKey === key ? '.tab[active]' : '.tab';
158        return m(
159          tag,
160          {
161            key,
162            onclick: (event: Event) => {
163              if (!event.defaultPrevented) {
164                this.trace.tabs.showTab(key);
165              }
166            },
167            // Middle click to close
168            onauxclick: (event: MouseEvent) => {
169              if (!event.defaultPrevented) {
170                this.trace.tabs.hideTab(key);
171              }
172            },
173          },
174          m('span.pf-tab-title', tab.title),
175          hasCloseButton &&
176            m(Button, {
177              onclick: (event: Event) => {
178                this.trace.tabs.hideTab(key);
179                event.preventDefault();
180              },
181              compact: true,
182              icon: 'close',
183            }),
184        );
185      }),
186    );
187  }
188
189  private renderCSTabContentWithFading(): m.Children {
190    const section = this.renderCSTabContent();
191    if (section.isLoading) {
192      return m(FadeIn, section.content);
193    } else {
194      return m(FadeOut, {context: this.fadeContext}, section.content);
195    }
196  }
197
198  private renderCSTabContent(): {isLoading: boolean; content: m.Children} {
199    const currentSelection = this.trace.selection.selection;
200    if (currentSelection.kind === 'empty') {
201      return {
202        isLoading: false,
203        content: m(
204          EmptyState,
205          {
206            className: 'pf-noselection',
207            title: 'Nothing selected',
208          },
209          'Selection details will appear here',
210        ),
211      };
212    }
213
214    if (currentSelection.kind === 'track') {
215      return {
216        isLoading: false,
217        content: this.renderTrackDetailsPanel(currentSelection.trackUri),
218      };
219    }
220
221    const detailsPanel = this.trace.selection.getDetailsPanelForSelection();
222    if (currentSelection.kind === 'track_event' && detailsPanel !== undefined) {
223      return {
224        isLoading: detailsPanel.isLoading,
225        content: detailsPanel.render(),
226      };
227    }
228
229    // Get the first "truthy" details panel
230    const detailsPanels = this.trace.tabs.detailsPanels.map((dp) => {
231      return {
232        content: dp.render(currentSelection),
233        isLoading: dp.isLoading?.() ?? false,
234      };
235    });
236
237    const panel = detailsPanels.find(({content}) => content);
238
239    if (panel) {
240      return panel;
241    } else {
242      return {
243        isLoading: false,
244        content: m(
245          EmptyState,
246          {
247            className: 'pf-noselection',
248            title: 'No details available',
249            icon: 'warning',
250          },
251          `Selection kind: '${currentSelection.kind}'`,
252        ),
253      };
254    }
255  }
256
257  private renderTrackDetailsPanel(trackUri: string) {
258    const track = this.trace.tracks.getTrack(trackUri);
259    if (track) {
260      return m(
261        DetailsShell,
262        {title: 'Track', description: track.title},
263        m(
264          GridLayout,
265          m(
266            GridLayoutColumn,
267            m(
268              Section,
269              {title: 'Details'},
270              m(
271                Tree,
272                m(TreeNode, {left: 'Name', right: track.title}),
273                m(TreeNode, {left: 'URI', right: track.uri}),
274                m(TreeNode, {left: 'Plugin ID', right: track.pluginId}),
275                m(
276                  TreeNode,
277                  {left: 'Tags'},
278                  track.tags &&
279                    Object.entries(track.tags).map(([key, value]) => {
280                      return m(TreeNode, {left: key, right: value?.toString()});
281                    }),
282                ),
283              ),
284            ),
285          ),
286        ),
287      );
288    } else {
289      return undefined; // TODO show something sensible here
290    }
291  }
292}
293
294const FADE_TIME_MS = 50;
295
296class FadeContext {
297  private resolver = () => {};
298
299  putResolver(res: () => void) {
300    this.resolver = res;
301  }
302
303  resolve() {
304    this.resolver();
305    this.resolver = () => {};
306  }
307}
308
309interface FadeOutAttrs {
310  context: FadeContext;
311}
312
313class FadeOut implements m.ClassComponent<FadeOutAttrs> {
314  onbeforeremove({attrs}: m.VnodeDOM<FadeOutAttrs>): Promise<void> {
315    return new Promise((res) => {
316      attrs.context.putResolver(res);
317      setTimeout(res, FADE_TIME_MS);
318    });
319  }
320
321  oncreate({attrs}: m.VnodeDOM<FadeOutAttrs>) {
322    attrs.context.resolve();
323  }
324
325  view(vnode: m.Vnode<FadeOutAttrs>): void | m.Children {
326    return vnode.children;
327  }
328}
329
330class FadeIn implements m.ClassComponent {
331  private show = false;
332
333  oncreate(_: m.VnodeDOM) {
334    setTimeout(() => {
335      this.show = true;
336      raf.scheduleFullRedraw();
337    }, FADE_TIME_MS);
338  }
339
340  view(vnode: m.Vnode): m.Children {
341    return this.show ? vnode.children : undefined;
342  }
343}
344