// 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 {DisposableStack} from '../base/disposable'; import {raf} from '../core/raf_scheduler'; import {Button} from '../widgets/button'; import {MenuItem, PopupMenu2} from '../widgets/menu'; import {DEFAULT_DETAILS_CONTENT_HEIGHT} from './css_constants'; import {DragGestureHandler} from './drag_gesture_handler'; import {globals} from './globals'; const DRAG_HANDLE_HEIGHT_PX = 28; const UP_ICON = 'keyboard_arrow_up'; const DOWN_ICON = 'keyboard_arrow_down'; 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; } 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 interface DragHandleAttrs { // The current height of the panel. height: number; // Called when the panel is dragged. resize: (height: number) => void; // A list of tabs to show in the tab bar. tabs: Tab[]; // The key of the "current" tab. currentTabKey?: string; // A list of entries to show in the tab dropdown. // If undefined, the tab dropdown button will not be displayed. tabDropdownEntries?: TabDropdownEntry[]; // Called when a tab is clicked. onTabClick: (key: string) => void; // Called when a tab is closed using its close button. onTabClose?: (key: string) => void; } export function getDefaultDetailsHeight() { // 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; } function getFullScreenHeight() { const page = document.querySelector('.page') as HTMLElement; if (page === null) { // Fall back to at least partially open. return getDefaultDetailsHeight(); } else { return page.clientHeight; } } export class DragHandle implements m.ClassComponent { private dragStartHeight = 0; private height = 0; private previousHeight = this.height; private resize: (height: number) => void = () => {}; private isClosed = this.height <= 0; private isFullscreen = false; // We can't get real fullscreen height until the pan_and_zoom_handler // exists. private fullscreenHeight = 0; private trash = new DisposableStack(); oncreate({dom, attrs}: m.CVnodeDOM) { this.resize = attrs.resize; this.height = attrs.height; this.isClosed = this.height <= 0; this.fullscreenHeight = getFullScreenHeight(); const elem = dom as HTMLElement; this.trash.use( new DragGestureHandler( elem, this.onDrag.bind(this), this.onDragStart.bind(this), this.onDragEnd.bind(this), ), ); const cmd = globals.commandManager.registerCommand({ id: 'perfetto.ToggleDrawer', name: 'Toggle drawer', defaultHotkey: 'Q', callback: () => { this.toggleVisibility(); }, }); this.trash.use(cmd); } private toggleVisibility() { if (this.height === 0) { this.isClosed = false; if (this.previousHeight === 0) { this.previousHeight = getDefaultDetailsHeight(); } this.resize(this.previousHeight); } else { this.isFullscreen = false; this.isClosed = true; this.previousHeight = this.height; this.resize(0); } raf.scheduleFullRedraw(); } onupdate({attrs}: m.CVnodeDOM) { this.resize = attrs.resize; this.height = attrs.height; this.isClosed = this.height <= 0; } onremove(_: m.CVnodeDOM) { this.trash.dispose(); } onDrag(_x: number, y: number) { const newHeight = Math.floor( this.dragStartHeight + DRAG_HANDLE_HEIGHT_PX / 2 - y, ); this.isClosed = newHeight <= 0; this.isFullscreen = newHeight >= this.fullscreenHeight; this.resize(newHeight); raf.scheduleFullRedraw(); } onDragStart(_x: number, _y: number) { this.dragStartHeight = this.height; } onDragEnd() {} view({attrs}: m.CVnode) { const { tabDropdownEntries, currentTabKey, tabs, onTabClick, onTabClose = () => {}, } = attrs; const icon = this.isClosed ? UP_ICON : DOWN_ICON; const title = this.isClosed ? 'Show panel' : 'Hide panel'; const renderTab = (tab: Tab) => { const {key, hasCloseButton = false} = tab; const tag = currentTabKey === key ? '.tab[active]' : '.tab'; return m( tag, { key, onclick: (event: Event) => { if (!event.defaultPrevented) { onTabClick(key); } }, // Middle click to close onauxclick: (event: MouseEvent) => { if (!event.defaultPrevented) { onTabClose(key); } }, }, m('span.pf-tab-title', tab.title), hasCloseButton && m(Button, { onclick: (event: Event) => { onTabClose(key); event.preventDefault(); }, compact: true, icon: 'close', }), ); }; return m( '.handle', m( '.buttons', tabDropdownEntries && this.renderTabDropdown(tabDropdownEntries), ), m('.tabs', tabs.map(renderTab)), m( '.buttons', m(Button, { onclick: () => { this.isClosed = false; this.isFullscreen = true; // Ensure fullscreenHeight is up to date. this.fullscreenHeight = getFullScreenHeight(); this.resize(this.fullscreenHeight); raf.scheduleFullRedraw(); }, title: 'Open fullscreen', disabled: this.isFullscreen, icon: 'vertical_align_top', compact: true, }), m(Button, { onclick: () => { this.toggleVisibility(); }, title, icon, compact: true, }), ), ); } private renderTabDropdown(entries: TabDropdownEntry[]) { return 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', }); }), ); } }