1// Copyright (C) 2019 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 {Draft, produce} from 'immer'; 16import * as m from 'mithril'; 17 18import {assertExists} from '../base/logging'; 19import {Actions} from '../common/actions'; 20import {RecordConfig} from '../controller/record_config_types'; 21 22import {copyToClipboard} from './clipboard'; 23import {globals} from './globals'; 24 25declare type Setter<T> = (draft: Draft<RecordConfig>, val: T) => void; 26declare type Getter<T> = (cfg: RecordConfig) => T; 27 28function defaultSort(a: string, b: string) { 29 return a.localeCompare(b); 30} 31 32// +---------------------------------------------------------------------------+ 33// | Docs link with 'i' in circle icon. | 34// +---------------------------------------------------------------------------+ 35 36interface DocsChipAttrs { 37 href: string; 38} 39 40class DocsChip implements m.ClassComponent<DocsChipAttrs> { 41 view({attrs}: m.CVnode<DocsChipAttrs>) { 42 return m( 43 'a.inline-chip', 44 {href: attrs.href, title: 'Open docs in new tab', target: '_blank'}, 45 m('i.material-icons', 'info'), 46 ' Docs'); 47 } 48} 49 50// +---------------------------------------------------------------------------+ 51// | Probe: the rectangular box on the right-hand-side with a toggle box. | 52// +---------------------------------------------------------------------------+ 53 54export interface ProbeAttrs { 55 title: string; 56 img: string|null; 57 compact?: boolean; 58 descr: m.Children; 59 isEnabled: Getter<boolean>; 60 setEnabled: Setter<boolean>; 61} 62 63export class Probe implements m.ClassComponent<ProbeAttrs> { 64 view({attrs, children}: m.CVnode<ProbeAttrs>) { 65 const onToggle = (enabled: boolean) => { 66 const traceCfg = produce(globals.state.recordConfig, draft => { 67 attrs.setEnabled(draft, enabled); 68 }); 69 globals.dispatch(Actions.setRecordConfig({config: traceCfg})); 70 }; 71 72 const enabled = attrs.isEnabled(globals.state.recordConfig); 73 74 return m( 75 `.probe${attrs.compact ? '.compact' : ''}${enabled ? '.enabled' : ''}`, 76 attrs.img && m('img', { 77 src: `${globals.root}assets/${attrs.img}`, 78 onclick: () => onToggle(!enabled), 79 }), 80 m('label', 81 m(`input[type=checkbox]`, { 82 checked: enabled, 83 oninput: (e: InputEvent) => { 84 onToggle((e.target as HTMLInputElement).checked); 85 }, 86 }), 87 m('span', attrs.title)), 88 attrs.compact ? 89 '' : 90 m('div', m('div', attrs.descr), m('.probe-config', children))); 91 } 92} 93 94export function CompactProbe(args: { 95 title: string, 96 isEnabled: Getter<boolean>, 97 setEnabled: Setter<boolean> 98}) { 99 return m(Probe, { 100 title: args.title, 101 img: null, 102 compact: true, 103 descr: '', 104 isEnabled: args.isEnabled, 105 setEnabled: args.setEnabled 106 } as ProbeAttrs); 107} 108 109// +-------------------------------------------------------------+ 110// | Toggle: an on/off switch. 111// +-------------------------------------------------------------+ 112 113export interface ToggleAttrs { 114 title: string; 115 descr: string; 116 cssClass?: string; 117 isEnabled: Getter<boolean>; 118 setEnabled: Setter<boolean>; 119} 120 121export class Toggle implements m.ClassComponent<ToggleAttrs> { 122 view({attrs}: m.CVnode<ToggleAttrs>) { 123 const onToggle = (enabled: boolean) => { 124 const traceCfg = produce(globals.state.recordConfig, draft => { 125 attrs.setEnabled(draft, enabled); 126 }); 127 globals.dispatch(Actions.setRecordConfig({config: traceCfg})); 128 }; 129 130 const enabled = attrs.isEnabled(globals.state.recordConfig); 131 132 return m( 133 `.toggle${enabled ? '.enabled' : ''}${attrs.cssClass || ''}`, 134 m('label', 135 m(`input[type=checkbox]`, { 136 checked: enabled, 137 oninput: (e: InputEvent) => { 138 onToggle((e.target as HTMLInputElement).checked); 139 }, 140 }), 141 m('span', attrs.title)), 142 m('.descr', attrs.descr)); 143 } 144} 145 146// +---------------------------------------------------------------------------+ 147// | Slider: draggable horizontal slider with numeric spinner. | 148// +---------------------------------------------------------------------------+ 149 150export interface SliderAttrs { 151 title: string; 152 icon?: string; 153 cssClass?: string; 154 isTime?: boolean; 155 unit: string; 156 values: number[]; 157 get: Getter<number>; 158 set: Setter<number>; 159 min?: number; 160 description?: string; 161 disabled?: boolean; 162} 163 164export class Slider implements m.ClassComponent<SliderAttrs> { 165 onValueChange(attrs: SliderAttrs, newVal: number) { 166 const traceCfg = produce(globals.state.recordConfig, draft => { 167 attrs.set(draft, newVal); 168 }); 169 globals.dispatch(Actions.setRecordConfig({config: traceCfg})); 170 } 171 172 173 onTimeValueChange(attrs: SliderAttrs, hms: string) { 174 try { 175 const date = new Date(`1970-01-01T${hms}.000Z`); 176 if (isNaN(date.getTime())) return; 177 this.onValueChange(attrs, date.getTime()); 178 } catch { 179 } 180 } 181 182 onSliderChange(attrs: SliderAttrs, newIdx: number) { 183 this.onValueChange(attrs, attrs.values[newIdx]); 184 } 185 186 view({attrs}: m.CVnode<SliderAttrs>) { 187 const id = attrs.title.replace(/[^a-z0-9]/gmi, '_').toLowerCase(); 188 const maxIdx = attrs.values.length - 1; 189 const val = attrs.get(globals.state.recordConfig); 190 const min = attrs.min; 191 const description = attrs.description; 192 const disabled = attrs.disabled; 193 194 // Find the index of the closest value in the slider. 195 let idx = 0; 196 for (; idx < attrs.values.length && attrs.values[idx] < val; idx++) { 197 } 198 199 let spinnerCfg = {}; 200 if (attrs.isTime) { 201 spinnerCfg = { 202 type: 'text', 203 pattern: '(0[0-9]|1[0-9]|2[0-3])(:[0-5][0-9]){2}', // hh:mm:ss 204 value: new Date(val).toISOString().substr(11, 8), 205 oninput: (e: InputEvent) => { 206 this.onTimeValueChange(attrs, (e.target as HTMLInputElement).value); 207 }, 208 }; 209 } else { 210 spinnerCfg = { 211 type: 'number', 212 value: val, 213 oninput: (e: InputEvent) => { 214 this.onTimeValueChange(attrs, (e.target as HTMLInputElement).value); 215 }, 216 }; 217 } 218 return m( 219 '.slider' + (attrs.cssClass || ''), 220 m('header', attrs.title), 221 description ? m('header.descr', attrs.description) : '', 222 attrs.icon !== undefined ? m('i.material-icons', attrs.icon) : [], 223 m(`input[id="${id}"][type=range][min=0][max=${maxIdx}][value=${idx}] 224 ${disabled ? '[disabled]' : ''}`, 225 { 226 oninput: (e: InputEvent) => { 227 this.onSliderChange(attrs, +(e.target as HTMLInputElement).value); 228 }, 229 }), 230 m(`input.spinner[min=${min !== undefined ? min : 1}][for=${id}]`, 231 spinnerCfg), 232 m('.unit', attrs.unit)); 233 } 234} 235 236// +---------------------------------------------------------------------------+ 237// | Dropdown: wrapper around <select>. Supports single an multiple selection. | 238// +---------------------------------------------------------------------------+ 239 240export interface DropdownAttrs { 241 title: string; 242 cssClass?: string; 243 options: Map<string, string>; 244 sort?: (a: string, b: string) => number; 245 get: Getter<string[]>; 246 set: Setter<string[]>; 247} 248 249export class Dropdown implements m.ClassComponent<DropdownAttrs> { 250 resetScroll(dom: HTMLSelectElement) { 251 // Chrome seems to override the scroll offset on creationa, b without this, 252 // even though we call it after having marked the options as selected. 253 setTimeout(() => { 254 // Don't reset the scroll position if the element is still focused. 255 if (dom !== document.activeElement) dom.scrollTop = 0; 256 }, 0); 257 } 258 259 onChange(attrs: DropdownAttrs, e: Event) { 260 const dom = e.target as HTMLSelectElement; 261 const selKeys: string[] = []; 262 for (let i = 0; i < dom.selectedOptions.length; i++) { 263 const item = assertExists(dom.selectedOptions.item(i)); 264 selKeys.push(item.value); 265 } 266 const traceCfg = produce(globals.state.recordConfig, draft => { 267 attrs.set(draft, selKeys); 268 }); 269 globals.dispatch(Actions.setRecordConfig({config: traceCfg})); 270 } 271 272 view({attrs}: m.CVnode<DropdownAttrs>) { 273 const options: m.Children = []; 274 const selItems = attrs.get(globals.state.recordConfig); 275 let numSelected = 0; 276 const entries = [...attrs.options.entries()]; 277 const f = attrs.sort === undefined ? defaultSort : attrs.sort; 278 entries.sort((a, b) => f(a[1], b[1])); 279 for (const [key, label] of entries) { 280 const opts = {value: key, selected: false}; 281 if (selItems.includes(key)) { 282 opts.selected = true; 283 numSelected++; 284 } 285 options.push(m('option', opts, label)); 286 } 287 const label = `${attrs.title} ${numSelected ? `(${numSelected})` : ''}`; 288 return m( 289 `select.dropdown${attrs.cssClass || ''}[multiple=multiple]`, 290 { 291 onblur: (e: Event) => this.resetScroll(e.target as HTMLSelectElement), 292 onmouseleave: (e: Event) => 293 this.resetScroll(e.target as HTMLSelectElement), 294 oninput: (e: Event) => this.onChange(attrs, e), 295 oncreate: (vnode) => this.resetScroll(vnode.dom as HTMLSelectElement), 296 }, 297 m('optgroup', {label}, options)); 298 } 299} 300 301 302// +---------------------------------------------------------------------------+ 303// | Textarea: wrapper around <textarea>. | 304// +---------------------------------------------------------------------------+ 305 306export interface TextareaAttrs { 307 placeholder: string; 308 docsLink?: string; 309 cssClass?: string; 310 get: Getter<string>; 311 set: Setter<string>; 312 title?: string; 313} 314 315export class Textarea implements m.ClassComponent<TextareaAttrs> { 316 onChange(attrs: TextareaAttrs, dom: HTMLTextAreaElement) { 317 const traceCfg = produce(globals.state.recordConfig, draft => { 318 attrs.set(draft, dom.value); 319 }); 320 globals.dispatch(Actions.setRecordConfig({config: traceCfg})); 321 } 322 323 view({attrs}: m.CVnode<TextareaAttrs>) { 324 return m( 325 '.textarea-holder', 326 m('header', 327 attrs.title, 328 attrs.docsLink && [' ', m(DocsChip, {href: attrs.docsLink})]), 329 m(`textarea.extra-input${attrs.cssClass || ''}`, { 330 onchange: (e: Event) => 331 this.onChange(attrs, e.target as HTMLTextAreaElement), 332 placeholder: attrs.placeholder, 333 value: attrs.get(globals.state.recordConfig) 334 })); 335 } 336} 337 338// +---------------------------------------------------------------------------+ 339// | CodeSnippet: command-prompt-like box with code snippets to copy/paste. | 340// +---------------------------------------------------------------------------+ 341 342export interface CodeSnippetAttrs { 343 text: string; 344 hardWhitespace?: boolean; 345} 346 347export class CodeSnippet implements m.ClassComponent<CodeSnippetAttrs> { 348 view({attrs}: m.CVnode<CodeSnippetAttrs>) { 349 return m( 350 '.code-snippet', 351 m('button', 352 { 353 title: 'Copy to clipboard', 354 onclick: () => copyToClipboard(attrs.text), 355 }, 356 m('i.material-icons', 'assignment')), 357 m('code', attrs.text), 358 ); 359 } 360} 361 362 363interface CategoriesCheckboxListParams { 364 categories: Map<string, string>; 365 title: string; 366 get: Getter<string[]>; 367 set: Setter<string[]>; 368} 369 370export class CategoriesCheckboxList implements 371 m.ClassComponent<CategoriesCheckboxListParams> { 372 updateValue( 373 attrs: CategoriesCheckboxListParams, value: string, enabled: boolean) { 374 const traceCfg = produce(globals.state.recordConfig, draft => { 375 const values = attrs.get(draft); 376 const index = values.indexOf(value); 377 if (enabled && index === -1) { 378 values.push(value); 379 } 380 if (!enabled && index !== -1) { 381 values.splice(index, 1); 382 } 383 }); 384 globals.dispatch(Actions.setRecordConfig({config: traceCfg})); 385 } 386 387 view({attrs}: m.CVnode<CategoriesCheckboxListParams>) { 388 const enabled = new Set(attrs.get(globals.state.recordConfig)); 389 return m( 390 '.categories-list', 391 m('h3', 392 attrs.title, 393 m('button.config-button', 394 { 395 onclick: () => { 396 const config = produce(globals.state.recordConfig, draft => { 397 attrs.set(draft, Array.from(attrs.categories.keys())); 398 }); 399 globals.dispatch(Actions.setRecordConfig({config})); 400 } 401 }, 402 'All'), 403 m('button.config-button', 404 { 405 onclick: () => { 406 const config = produce(globals.state.recordConfig, draft => { 407 attrs.set(draft, []); 408 }); 409 globals.dispatch(Actions.setRecordConfig({config})); 410 }, 411 }, 412 'None')), 413 m('ul.checkboxes', 414 Array.from(attrs.categories.entries()).map(([key, value]) => { 415 const id = `category-checkbox-${key}`; 416 return m( 417 'label', 418 {'for': id}, 419 m('li', 420 m('input[type=checkbox]', { 421 id, 422 checked: enabled.has(key), 423 onclick: (e: InputEvent) => { 424 const target = e.target as HTMLInputElement; 425 this.updateValue(attrs, key, target.checked); 426 } 427 }), 428 value)); 429 }))); 430 } 431} 432