1// Copyright (C) 2018 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 * as m from 'mithril'; 16 17import {Actions} from '../common/actions'; 18import {EngineConfig} from '../common/state'; 19import * as version from '../gen/perfetto_version'; 20 21import {globals} from './globals'; 22import {executeSearch} from './search_handler'; 23import {taskTracker} from './task_tracker'; 24 25const SEARCH = Symbol('search'); 26const COMMAND = Symbol('command'); 27type Mode = typeof SEARCH|typeof COMMAND; 28 29const PLACEHOLDER = { 30 [SEARCH]: 'Search', 31 [COMMAND]: 'e.g. select * from sched left join thread using(utid) limit 10' 32}; 33 34export const DISMISSED_PANNING_HINT_KEY = 'dismissedPanningHint'; 35 36let mode: Mode = SEARCH; 37let displayStepThrough = false; 38 39function onKeyDown(e: Event) { 40 const event = (e as KeyboardEvent); 41 const key = event.key; 42 if (key !== 'Enter') { 43 e.stopPropagation(); 44 } 45 const txt = (e.target as HTMLInputElement); 46 47 if (mode === SEARCH && txt.value === '' && key === ':') { 48 e.preventDefault(); 49 mode = COMMAND; 50 globals.rafScheduler.scheduleFullRedraw(); 51 return; 52 } 53 54 if (mode === COMMAND && txt.value === '' && key === 'Backspace') { 55 mode = SEARCH; 56 globals.rafScheduler.scheduleFullRedraw(); 57 return; 58 } 59 60 if (mode === SEARCH && key === 'Enter') { 61 txt.blur(); 62 } 63} 64 65function onKeyUp(e: Event) { 66 e.stopPropagation(); 67 const event = (e as KeyboardEvent); 68 const key = event.key; 69 const txt = e.target as HTMLInputElement; 70 71 if (key === 'Escape') { 72 globals.dispatch(Actions.deleteQuery({queryId: 'command'})); 73 mode = SEARCH; 74 txt.value = ''; 75 txt.blur(); 76 globals.rafScheduler.scheduleFullRedraw(); 77 return; 78 } 79 if (mode === COMMAND && key === 'Enter') { 80 globals.dispatch(Actions.executeQuery( 81 {engineId: '0', queryId: 'command', query: txt.value})); 82 } 83} 84 85class Omnibox implements m.ClassComponent { 86 oncreate(vnode: m.VnodeDOM) { 87 const txt = vnode.dom.querySelector('input') as HTMLInputElement; 88 txt.addEventListener('keydown', onKeyDown); 89 txt.addEventListener('keyup', onKeyUp); 90 } 91 92 view() { 93 const msgTTL = globals.state.status.timestamp + 1 - Date.now() / 1e3; 94 let enginesAreBusy = false; 95 for (const engine of Object.values(globals.state.engines)) { 96 enginesAreBusy = enginesAreBusy || !engine.ready; 97 } 98 99 if (msgTTL > 0 || enginesAreBusy) { 100 setTimeout( 101 () => globals.rafScheduler.scheduleFullRedraw(), msgTTL * 1000); 102 return m( 103 `.omnibox.message-mode`, 104 m(`input[placeholder=${globals.state.status.msg}][readonly]`, { 105 value: '', 106 })); 107 } 108 109 const commandMode = mode === COMMAND; 110 const state = globals.frontendLocalState; 111 return m( 112 `.omnibox${commandMode ? '.command-mode' : ''}`, 113 m('input', { 114 placeholder: PLACEHOLDER[mode], 115 oninput: (e: InputEvent) => { 116 const value = (e.target as HTMLInputElement).value; 117 globals.frontendLocalState.setOmnibox( 118 value, commandMode ? 'COMMAND' : 'SEARCH'); 119 if (mode === SEARCH) { 120 globals.frontendLocalState.setSearchIndex(-1); 121 displayStepThrough = value.length >= 4; 122 globals.rafScheduler.scheduleFullRedraw(); 123 } 124 }, 125 value: globals.frontendLocalState.omnibox, 126 }), 127 displayStepThrough ? 128 m( 129 '.stepthrough', 130 m('.current', 131 `${ 132 globals.currentSearchResults.totalResults === 0 ? 133 '0 / 0' : 134 `${state.searchIndex + 1} / ${ 135 globals.currentSearchResults.totalResults}`}`), 136 m('button', 137 { 138 disabled: state.searchIndex <= 0, 139 onclick: () => { 140 executeSearch(true /* reverse direction */); 141 } 142 }, 143 m('i.material-icons.left', 'keyboard_arrow_left')), 144 m('button', 145 { 146 disabled: state.searchIndex === 147 globals.currentSearchResults.totalResults - 1, 148 onclick: () => { 149 executeSearch(); 150 } 151 }, 152 m('i.material-icons.right', 'keyboard_arrow_right')), 153 ) : 154 ''); 155 } 156} 157 158class Progress implements m.ClassComponent { 159 private loading: () => void; 160 private progressBar?: HTMLElement; 161 162 constructor() { 163 this.loading = () => this.loadingAnimation(); 164 } 165 166 oncreate(vnodeDom: m.CVnodeDOM) { 167 this.progressBar = vnodeDom.dom as HTMLElement; 168 globals.rafScheduler.addRedrawCallback(this.loading); 169 } 170 171 onremove() { 172 globals.rafScheduler.removeRedrawCallback(this.loading); 173 } 174 175 view() { 176 return m('.progress'); 177 } 178 179 loadingAnimation() { 180 if (this.progressBar === undefined) return; 181 const engine: EngineConfig = globals.state.engines['0']; 182 if ((engine !== undefined && !engine.ready) || 183 globals.numQueuedQueries > 0 || taskTracker.hasPendingTasks()) { 184 this.progressBar.classList.add('progress-anim'); 185 } else { 186 this.progressBar.classList.remove('progress-anim'); 187 } 188 } 189} 190 191 192class NewVersionNotification implements m.ClassComponent { 193 view() { 194 return m( 195 '.new-version-toast', 196 `Updated to ${version.VERSION} and ready for offline use!`, 197 m('button.notification-btn.preferred', 198 { 199 onclick: () => { 200 globals.frontendLocalState.newVersionAvailable = false; 201 globals.rafScheduler.scheduleFullRedraw(); 202 } 203 }, 204 'Dismiss'), 205 ); 206 } 207} 208 209 210class HelpPanningNotification implements m.ClassComponent { 211 view() { 212 const dismissed = localStorage.getItem(DISMISSED_PANNING_HINT_KEY); 213 if (dismissed === 'true' || !globals.frontendLocalState.showPanningHint) { 214 return; 215 } 216 return m( 217 '.helpful-hint', 218 m('.hint-text', 219 'Are you trying to pan? Use the WASD keys or hold shift to click ' + 220 'and drag. Press \'?\' for more help.'), 221 m('button.hint-dismiss-button', 222 { 223 onclick: () => { 224 globals.frontendLocalState.showPanningHint = false; 225 localStorage.setItem(DISMISSED_PANNING_HINT_KEY, 'true'); 226 globals.rafScheduler.scheduleFullRedraw(); 227 } 228 }, 229 'Dismiss'), 230 ); 231 } 232} 233 234class TraceErrorIcon implements m.ClassComponent { 235 view() { 236 const errors = globals.traceErrors; 237 if (!errors && !globals.metricError || mode === COMMAND) return; 238 const message = errors ? `${errors} import or data loss errors detected.` : 239 `Metric error detected.`; 240 return m( 241 'a.error', 242 {href: '#!/info'}, 243 m('i.material-icons', 244 { 245 title: message + ` Click for more info.`, 246 }, 247 'announcement')); 248 } 249} 250 251export class Topbar implements m.ClassComponent { 252 view() { 253 return m( 254 '.topbar', 255 { 256 class: globals.frontendLocalState.sidebarVisible ? '' : 'hide-sidebar' 257 }, 258 globals.frontendLocalState.newVersionAvailable ? 259 m(NewVersionNotification) : 260 m(Omnibox), 261 m(Progress), 262 m(HelpPanningNotification), 263 m(TraceErrorIcon)); 264 } 265} 266