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'; 16 17import {DisposableStack} from '../base/disposable'; 18import {raf} from '../core/raf_scheduler'; 19import {Button} from '../widgets/button'; 20import {MenuItem, PopupMenu2} from '../widgets/menu'; 21 22import {DEFAULT_DETAILS_CONTENT_HEIGHT} from './css_constants'; 23import {DragGestureHandler} from './drag_gesture_handler'; 24import {globals} from './globals'; 25 26const DRAG_HANDLE_HEIGHT_PX = 28; 27const UP_ICON = 'keyboard_arrow_up'; 28const DOWN_ICON = 'keyboard_arrow_down'; 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 42export interface TabDropdownEntry { 43 // Unique key for this tab dropdown entry. 44 key: string; 45 46 // Title to show on this entry. 47 title: string; 48 49 // Called when tab dropdown entry is clicked. 50 onClick: () => void; 51 52 // Whether this tab is checked or not 53 checked: boolean; 54} 55 56export interface DragHandleAttrs { 57 // The current height of the panel. 58 height: number; 59 60 // Called when the panel is dragged. 61 resize: (height: number) => void; 62 63 // A list of tabs to show in the tab bar. 64 tabs: Tab[]; 65 66 // The key of the "current" tab. 67 currentTabKey?: string; 68 69 // A list of entries to show in the tab dropdown. 70 // If undefined, the tab dropdown button will not be displayed. 71 tabDropdownEntries?: TabDropdownEntry[]; 72 73 // Called when a tab is clicked. 74 onTabClick: (key: string) => void; 75 76 // Called when a tab is closed using its close button. 77 onTabClose?: (key: string) => void; 78} 79 80export function getDefaultDetailsHeight() { 81 // This needs to be a function instead of a const to ensure the CSS constants 82 // have been initialized by the time we perform this calculation; 83 return DRAG_HANDLE_HEIGHT_PX + DEFAULT_DETAILS_CONTENT_HEIGHT; 84} 85 86function getFullScreenHeight() { 87 const page = document.querySelector('.page') as HTMLElement; 88 if (page === null) { 89 // Fall back to at least partially open. 90 return getDefaultDetailsHeight(); 91 } else { 92 return page.clientHeight; 93 } 94} 95 96export class DragHandle implements m.ClassComponent<DragHandleAttrs> { 97 private dragStartHeight = 0; 98 private height = 0; 99 private previousHeight = this.height; 100 private resize: (height: number) => void = () => {}; 101 private isClosed = this.height <= 0; 102 private isFullscreen = false; 103 // We can't get real fullscreen height until the pan_and_zoom_handler 104 // exists. 105 private fullscreenHeight = 0; 106 private trash = new DisposableStack(); 107 108 oncreate({dom, attrs}: m.CVnodeDOM<DragHandleAttrs>) { 109 this.resize = attrs.resize; 110 this.height = attrs.height; 111 this.isClosed = this.height <= 0; 112 this.fullscreenHeight = getFullScreenHeight(); 113 const elem = dom as HTMLElement; 114 this.trash.use( 115 new DragGestureHandler( 116 elem, 117 this.onDrag.bind(this), 118 this.onDragStart.bind(this), 119 this.onDragEnd.bind(this), 120 ), 121 ); 122 const cmd = globals.commandManager.registerCommand({ 123 id: 'perfetto.ToggleDrawer', 124 name: 'Toggle drawer', 125 defaultHotkey: 'Q', 126 callback: () => { 127 this.toggleVisibility(); 128 }, 129 }); 130 this.trash.use(cmd); 131 } 132 133 private toggleVisibility() { 134 if (this.height === 0) { 135 this.isClosed = false; 136 if (this.previousHeight === 0) { 137 this.previousHeight = getDefaultDetailsHeight(); 138 } 139 this.resize(this.previousHeight); 140 } else { 141 this.isFullscreen = false; 142 this.isClosed = true; 143 this.previousHeight = this.height; 144 this.resize(0); 145 } 146 raf.scheduleFullRedraw(); 147 } 148 149 onupdate({attrs}: m.CVnodeDOM<DragHandleAttrs>) { 150 this.resize = attrs.resize; 151 this.height = attrs.height; 152 this.isClosed = this.height <= 0; 153 } 154 155 onremove(_: m.CVnodeDOM<DragHandleAttrs>) { 156 this.trash.dispose(); 157 } 158 159 onDrag(_x: number, y: number) { 160 const newHeight = Math.floor( 161 this.dragStartHeight + DRAG_HANDLE_HEIGHT_PX / 2 - y, 162 ); 163 this.isClosed = newHeight <= 0; 164 this.isFullscreen = newHeight >= this.fullscreenHeight; 165 this.resize(newHeight); 166 raf.scheduleFullRedraw(); 167 } 168 169 onDragStart(_x: number, _y: number) { 170 this.dragStartHeight = this.height; 171 } 172 173 onDragEnd() {} 174 175 view({attrs}: m.CVnode<DragHandleAttrs>) { 176 const { 177 tabDropdownEntries, 178 currentTabKey, 179 tabs, 180 onTabClick, 181 onTabClose = () => {}, 182 } = attrs; 183 184 const icon = this.isClosed ? UP_ICON : DOWN_ICON; 185 const title = this.isClosed ? 'Show panel' : 'Hide panel'; 186 const renderTab = (tab: Tab) => { 187 const {key, hasCloseButton = false} = tab; 188 const tag = currentTabKey === key ? '.tab[active]' : '.tab'; 189 return m( 190 tag, 191 { 192 key, 193 onclick: (event: Event) => { 194 if (!event.defaultPrevented) { 195 onTabClick(key); 196 } 197 }, 198 // Middle click to close 199 onauxclick: (event: MouseEvent) => { 200 if (!event.defaultPrevented) { 201 onTabClose(key); 202 } 203 }, 204 }, 205 m('span.pf-tab-title', tab.title), 206 hasCloseButton && 207 m(Button, { 208 onclick: (event: Event) => { 209 onTabClose(key); 210 event.preventDefault(); 211 }, 212 compact: true, 213 icon: 'close', 214 }), 215 ); 216 }; 217 218 return m( 219 '.handle', 220 m( 221 '.buttons', 222 tabDropdownEntries && this.renderTabDropdown(tabDropdownEntries), 223 ), 224 m('.tabs', tabs.map(renderTab)), 225 m( 226 '.buttons', 227 m(Button, { 228 onclick: () => { 229 this.isClosed = false; 230 this.isFullscreen = true; 231 // Ensure fullscreenHeight is up to date. 232 this.fullscreenHeight = getFullScreenHeight(); 233 this.resize(this.fullscreenHeight); 234 raf.scheduleFullRedraw(); 235 }, 236 title: 'Open fullscreen', 237 disabled: this.isFullscreen, 238 icon: 'vertical_align_top', 239 compact: true, 240 }), 241 m(Button, { 242 onclick: () => { 243 this.toggleVisibility(); 244 }, 245 title, 246 icon, 247 compact: true, 248 }), 249 ), 250 ); 251 } 252 253 private renderTabDropdown(entries: TabDropdownEntry[]) { 254 return m( 255 PopupMenu2, 256 { 257 trigger: m(Button, { 258 compact: true, 259 icon: 'more_vert', 260 disabled: entries.length === 0, 261 title: 'More Tabs', 262 }), 263 }, 264 entries.map((entry) => { 265 return m(MenuItem, { 266 key: entry.key, 267 label: entry.title, 268 onclick: () => entry.onClick(), 269 icon: entry.checked ? 'check_box' : 'check_box_outline_blank', 270 }); 271 }), 272 ); 273 } 274} 275