|  | // Copyright (C) 2019 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 {Draft, produce} from 'immer'; | 
|  | import m from 'mithril'; | 
|  |  | 
|  | import {assertExists} from '../base/logging'; | 
|  | import {Actions} from '../common/actions'; | 
|  | import {RecordConfig} from '../controller/record_config_types'; | 
|  |  | 
|  | import {copyToClipboard} from './clipboard'; | 
|  | import {globals} from './globals'; | 
|  |  | 
|  | declare type Setter<T> = (draft: Draft<RecordConfig>, val: T) => void; | 
|  | declare type Getter<T> = (cfg: RecordConfig) => T; | 
|  |  | 
|  | function defaultSort(a: string, b: string) { | 
|  | return a.localeCompare(b); | 
|  | } | 
|  |  | 
|  | // +---------------------------------------------------------------------------+ | 
|  | // | Docs link with 'i' in circle icon.                                        | | 
|  | // +---------------------------------------------------------------------------+ | 
|  |  | 
|  | interface DocsChipAttrs { | 
|  | href: string; | 
|  | } | 
|  |  | 
|  | class DocsChip implements m.ClassComponent<DocsChipAttrs> { | 
|  | view({attrs}: m.CVnode<DocsChipAttrs>) { | 
|  | return m( | 
|  | 'a.inline-chip', | 
|  | {href: attrs.href, title: 'Open docs in new tab', target: '_blank'}, | 
|  | m('i.material-icons', 'info'), | 
|  | ' Docs'); | 
|  | } | 
|  | } | 
|  |  | 
|  | // +---------------------------------------------------------------------------+ | 
|  | // | Probe: the rectangular box on the right-hand-side with a toggle box.      | | 
|  | // +---------------------------------------------------------------------------+ | 
|  |  | 
|  | export interface ProbeAttrs { | 
|  | title: string; | 
|  | img: string|null; | 
|  | compact?: boolean; | 
|  | descr: m.Children; | 
|  | isEnabled: Getter<boolean>; | 
|  | setEnabled: Setter<boolean>; | 
|  | } | 
|  |  | 
|  | export class Probe implements m.ClassComponent<ProbeAttrs> { | 
|  | view({attrs, children}: m.CVnode<ProbeAttrs>) { | 
|  | const onToggle = (enabled: boolean) => { | 
|  | const traceCfg = produce(globals.state.recordConfig, (draft) => { | 
|  | attrs.setEnabled(draft, enabled); | 
|  | }); | 
|  | globals.dispatch(Actions.setRecordConfig({config: traceCfg})); | 
|  | }; | 
|  |  | 
|  | const enabled = attrs.isEnabled(globals.state.recordConfig); | 
|  |  | 
|  | return m( | 
|  | `.probe${attrs.compact ? '.compact' : ''}${enabled ? '.enabled' : ''}`, | 
|  | attrs.img && m('img', { | 
|  | src: `${globals.root}assets/${attrs.img}`, | 
|  | onclick: () => onToggle(!enabled), | 
|  | }), | 
|  | m('label', | 
|  | m(`input[type=checkbox]`, { | 
|  | checked: enabled, | 
|  | oninput: (e: InputEvent) => { | 
|  | onToggle((e.target as HTMLInputElement).checked); | 
|  | }, | 
|  | }), | 
|  | m('span', attrs.title)), | 
|  | attrs.compact ? | 
|  | '' : | 
|  | m('div', m('div', attrs.descr), m('.probe-config', children))); | 
|  | } | 
|  | } | 
|  |  | 
|  | export function CompactProbe(args: { | 
|  | title: string, | 
|  | isEnabled: Getter<boolean>, | 
|  | setEnabled: Setter<boolean> | 
|  | }) { | 
|  | return m(Probe, { | 
|  | title: args.title, | 
|  | img: null, | 
|  | compact: true, | 
|  | descr: '', | 
|  | isEnabled: args.isEnabled, | 
|  | setEnabled: args.setEnabled, | 
|  | } as ProbeAttrs); | 
|  | } | 
|  |  | 
|  | // +-------------------------------------------------------------+ | 
|  | // | Toggle: an on/off switch. | 
|  | // +-------------------------------------------------------------+ | 
|  |  | 
|  | export interface ToggleAttrs { | 
|  | title: string; | 
|  | descr: string; | 
|  | cssClass?: string; | 
|  | isEnabled: Getter<boolean>; | 
|  | setEnabled: Setter<boolean>; | 
|  | } | 
|  |  | 
|  | export class Toggle implements m.ClassComponent<ToggleAttrs> { | 
|  | view({attrs}: m.CVnode<ToggleAttrs>) { | 
|  | const onToggle = (enabled: boolean) => { | 
|  | const traceCfg = produce(globals.state.recordConfig, (draft) => { | 
|  | attrs.setEnabled(draft, enabled); | 
|  | }); | 
|  | globals.dispatch(Actions.setRecordConfig({config: traceCfg})); | 
|  | }; | 
|  |  | 
|  | const enabled = attrs.isEnabled(globals.state.recordConfig); | 
|  |  | 
|  | return m( | 
|  | `.toggle${enabled ? '.enabled' : ''}${attrs.cssClass || ''}`, | 
|  | m('label', | 
|  | m(`input[type=checkbox]`, { | 
|  | checked: enabled, | 
|  | oninput: (e: InputEvent) => { | 
|  | onToggle((e.target as HTMLInputElement).checked); | 
|  | }, | 
|  | }), | 
|  | m('span', attrs.title)), | 
|  | m('.descr', attrs.descr)); | 
|  | } | 
|  | } | 
|  |  | 
|  | // +---------------------------------------------------------------------------+ | 
|  | // | Slider: draggable horizontal slider with numeric spinner.                 | | 
|  | // +---------------------------------------------------------------------------+ | 
|  |  | 
|  | export interface SliderAttrs { | 
|  | title: string; | 
|  | icon?: string; | 
|  | cssClass?: string; | 
|  | isTime?: boolean; | 
|  | unit: string; | 
|  | values: number[]; | 
|  | get: Getter<number>; | 
|  | set: Setter<number>; | 
|  | min?: number; | 
|  | description?: string; | 
|  | disabled?: boolean; | 
|  | zeroIsDefault?: boolean; | 
|  | } | 
|  |  | 
|  | export class Slider implements m.ClassComponent<SliderAttrs> { | 
|  | onValueChange(attrs: SliderAttrs, newVal: number) { | 
|  | const traceCfg = produce(globals.state.recordConfig, (draft) => { | 
|  | attrs.set(draft, newVal); | 
|  | }); | 
|  | globals.dispatch(Actions.setRecordConfig({config: traceCfg})); | 
|  | } | 
|  |  | 
|  | onTimeValueChange(attrs: SliderAttrs, hms: string) { | 
|  | try { | 
|  | const date = new Date(`1970-01-01T${hms}.000Z`); | 
|  | if (isNaN(date.getTime())) return; | 
|  | this.onValueChange(attrs, date.getTime()); | 
|  | } catch { | 
|  | } | 
|  | } | 
|  |  | 
|  | onSliderChange(attrs: SliderAttrs, newIdx: number) { | 
|  | this.onValueChange(attrs, attrs.values[newIdx]); | 
|  | } | 
|  |  | 
|  | view({attrs}: m.CVnode<SliderAttrs>) { | 
|  | const id = attrs.title.replace(/[^a-z0-9]/gmi, '_').toLowerCase(); | 
|  | const maxIdx = attrs.values.length - 1; | 
|  | const val = attrs.get(globals.state.recordConfig); | 
|  | let min = attrs.min || 1; | 
|  | if (attrs.zeroIsDefault) { | 
|  | min = Math.min(0, min); | 
|  | } | 
|  | const description = attrs.description; | 
|  | const disabled = attrs.disabled; | 
|  |  | 
|  | // Find the index of the closest value in the slider. | 
|  | let idx = 0; | 
|  | for (; idx < attrs.values.length && attrs.values[idx] < val; idx++) { | 
|  | } | 
|  |  | 
|  | let spinnerCfg = {}; | 
|  | if (attrs.isTime) { | 
|  | spinnerCfg = { | 
|  | type: 'text', | 
|  | pattern: '(0[0-9]|1[0-9]|2[0-3])(:[0-5][0-9]){2}',  // hh:mm:ss | 
|  | value: new Date(val).toISOString().substr(11, 8), | 
|  | oninput: (e: InputEvent) => { | 
|  | this.onTimeValueChange(attrs, (e.target as HTMLInputElement).value); | 
|  | }, | 
|  | }; | 
|  | } else { | 
|  | const isDefault = attrs.zeroIsDefault && val === 0; | 
|  | spinnerCfg = { | 
|  | type: 'number', | 
|  | value: isDefault ? '' : val, | 
|  | placeholder: isDefault ? '(default)' : '', | 
|  | oninput: (e: InputEvent) => { | 
|  | this.onValueChange(attrs, +(e.target as HTMLInputElement).value); | 
|  | }, | 
|  | }; | 
|  | } | 
|  | return m( | 
|  | '.slider' + (attrs.cssClass || ''), | 
|  | m('header', attrs.title), | 
|  | description ? m('header.descr', attrs.description) : '', | 
|  | attrs.icon !== undefined ? m('i.material-icons', attrs.icon) : [], | 
|  | m(`input[id="${id}"][type=range][min=0][max=${maxIdx}][value=${idx}]`, { | 
|  | disabled, | 
|  | oninput: (e: InputEvent) => { | 
|  | this.onSliderChange(attrs, +(e.target as HTMLInputElement).value); | 
|  | }, | 
|  | }), | 
|  | m(`input.spinner[min=${min}][for=${id}]`, spinnerCfg), | 
|  | m('.unit', attrs.unit)); | 
|  | } | 
|  | } | 
|  |  | 
|  | // +---------------------------------------------------------------------------+ | 
|  | // | Dropdown: wrapper around <select>. Supports single an multiple selection. | | 
|  | // +---------------------------------------------------------------------------+ | 
|  |  | 
|  | export interface DropdownAttrs { | 
|  | title: string; | 
|  | cssClass?: string; | 
|  | options: Map<string, string>; | 
|  | sort?: (a: string, b: string) => number; | 
|  | get: Getter<string[]>; | 
|  | set: Setter<string[]>; | 
|  | } | 
|  |  | 
|  | export class Dropdown implements m.ClassComponent<DropdownAttrs> { | 
|  | resetScroll(dom: HTMLSelectElement) { | 
|  | // Chrome seems to override the scroll offset on creationa, b without this, | 
|  | // even though we call it after having marked the options as selected. | 
|  | setTimeout(() => { | 
|  | // Don't reset the scroll position if the element is still focused. | 
|  | if (dom !== document.activeElement) dom.scrollTop = 0; | 
|  | }, 0); | 
|  | } | 
|  |  | 
|  | onChange(attrs: DropdownAttrs, e: Event) { | 
|  | const dom = e.target as HTMLSelectElement; | 
|  | const selKeys: string[] = []; | 
|  | for (let i = 0; i < dom.selectedOptions.length; i++) { | 
|  | const item = assertExists(dom.selectedOptions.item(i)); | 
|  | selKeys.push(item.value); | 
|  | } | 
|  | const traceCfg = produce(globals.state.recordConfig, (draft) => { | 
|  | attrs.set(draft, selKeys); | 
|  | }); | 
|  | globals.dispatch(Actions.setRecordConfig({config: traceCfg})); | 
|  | } | 
|  |  | 
|  | view({attrs}: m.CVnode<DropdownAttrs>) { | 
|  | const options: m.Children = []; | 
|  | const selItems = attrs.get(globals.state.recordConfig); | 
|  | let numSelected = 0; | 
|  | const entries = [...attrs.options.entries()]; | 
|  | const f = attrs.sort === undefined ? defaultSort : attrs.sort; | 
|  | entries.sort((a, b) => f(a[1], b[1])); | 
|  | for (const [key, label] of entries) { | 
|  | const opts = {value: key, selected: false}; | 
|  | if (selItems.includes(key)) { | 
|  | opts.selected = true; | 
|  | numSelected++; | 
|  | } | 
|  | options.push(m('option', opts, label)); | 
|  | } | 
|  | const label = `${attrs.title} ${numSelected ? `(${numSelected})` : ''}`; | 
|  | return m( | 
|  | `select.dropdown${attrs.cssClass || ''}[multiple=multiple]`, | 
|  | { | 
|  | onblur: (e: Event) => this.resetScroll(e.target as HTMLSelectElement), | 
|  | onmouseleave: (e: Event) => | 
|  | this.resetScroll(e.target as HTMLSelectElement), | 
|  | oninput: (e: Event) => this.onChange(attrs, e), | 
|  | oncreate: (vnode) => this.resetScroll(vnode.dom as HTMLSelectElement), | 
|  | }, | 
|  | m('optgroup', {label}, options)); | 
|  | } | 
|  | } | 
|  |  | 
|  |  | 
|  | // +---------------------------------------------------------------------------+ | 
|  | // | Textarea: wrapper around <textarea>.                                      | | 
|  | // +---------------------------------------------------------------------------+ | 
|  |  | 
|  | export interface TextareaAttrs { | 
|  | placeholder: string; | 
|  | docsLink?: string; | 
|  | cssClass?: string; | 
|  | get: Getter<string>; | 
|  | set: Setter<string>; | 
|  | title?: string; | 
|  | } | 
|  |  | 
|  | export class Textarea implements m.ClassComponent<TextareaAttrs> { | 
|  | onChange(attrs: TextareaAttrs, dom: HTMLTextAreaElement) { | 
|  | const traceCfg = produce(globals.state.recordConfig, (draft) => { | 
|  | attrs.set(draft, dom.value); | 
|  | }); | 
|  | globals.dispatch(Actions.setRecordConfig({config: traceCfg})); | 
|  | } | 
|  |  | 
|  | view({attrs}: m.CVnode<TextareaAttrs>) { | 
|  | return m( | 
|  | '.textarea-holder', | 
|  | m('header', | 
|  | attrs.title, | 
|  | attrs.docsLink && [' ', m(DocsChip, {href: attrs.docsLink})]), | 
|  | m(`textarea.extra-input${attrs.cssClass || ''}`, { | 
|  | onchange: (e: Event) => | 
|  | this.onChange(attrs, e.target as HTMLTextAreaElement), | 
|  | placeholder: attrs.placeholder, | 
|  | value: attrs.get(globals.state.recordConfig), | 
|  | })); | 
|  | } | 
|  | } | 
|  |  | 
|  | // +---------------------------------------------------------------------------+ | 
|  | // | CodeSnippet: command-prompt-like box with code snippets to copy/paste.    | | 
|  | // +---------------------------------------------------------------------------+ | 
|  |  | 
|  | export interface CodeSnippetAttrs { | 
|  | text: string; | 
|  | hardWhitespace?: boolean; | 
|  | } | 
|  |  | 
|  | export class CodeSnippet implements m.ClassComponent<CodeSnippetAttrs> { | 
|  | view({attrs}: m.CVnode<CodeSnippetAttrs>) { | 
|  | return m( | 
|  | '.code-snippet', | 
|  | m('button', | 
|  | { | 
|  | title: 'Copy to clipboard', | 
|  | onclick: () => copyToClipboard(attrs.text), | 
|  | }, | 
|  | m('i.material-icons', 'assignment')), | 
|  | m('code', attrs.text), | 
|  | ); | 
|  | } | 
|  | } | 
|  |  | 
|  |  | 
|  | interface CategoriesCheckboxListParams { | 
|  | categories: Map<string, string>; | 
|  | title: string; | 
|  | get: Getter<string[]>; | 
|  | set: Setter<string[]>; | 
|  | } | 
|  |  | 
|  | export class CategoriesCheckboxList implements | 
|  | m.ClassComponent<CategoriesCheckboxListParams> { | 
|  | updateValue( | 
|  | attrs: CategoriesCheckboxListParams, value: string, enabled: boolean) { | 
|  | const traceCfg = produce(globals.state.recordConfig, (draft) => { | 
|  | const values = attrs.get(draft); | 
|  | const index = values.indexOf(value); | 
|  | if (enabled && index === -1) { | 
|  | values.push(value); | 
|  | } | 
|  | if (!enabled && index !== -1) { | 
|  | values.splice(index, 1); | 
|  | } | 
|  | }); | 
|  | globals.dispatch(Actions.setRecordConfig({config: traceCfg})); | 
|  | } | 
|  |  | 
|  | view({attrs}: m.CVnode<CategoriesCheckboxListParams>) { | 
|  | const enabled = new Set(attrs.get(globals.state.recordConfig)); | 
|  | return m( | 
|  | '.categories-list', | 
|  | m('h3', | 
|  | attrs.title, | 
|  | m('button.config-button', | 
|  | { | 
|  | onclick: () => { | 
|  | const config = produce(globals.state.recordConfig, (draft) => { | 
|  | attrs.set(draft, Array.from(attrs.categories.keys())); | 
|  | }); | 
|  | globals.dispatch(Actions.setRecordConfig({config})); | 
|  | }, | 
|  | }, | 
|  | 'All'), | 
|  | m('button.config-button', | 
|  | { | 
|  | onclick: () => { | 
|  | const config = produce(globals.state.recordConfig, (draft) => { | 
|  | attrs.set(draft, []); | 
|  | }); | 
|  | globals.dispatch(Actions.setRecordConfig({config})); | 
|  | }, | 
|  | }, | 
|  | 'None')), | 
|  | m('ul.checkboxes', | 
|  | Array.from(attrs.categories.entries()).map(([key, value]) => { | 
|  | const id = `category-checkbox-${key}`; | 
|  | return m( | 
|  | 'label', | 
|  | {'for': id}, | 
|  | m('li', | 
|  | m('input[type=checkbox]', { | 
|  | id, | 
|  | checked: enabled.has(key), | 
|  | onclick: (e: InputEvent) => { | 
|  | const target = e.target as HTMLInputElement; | 
|  | this.updateValue(attrs, key, target.checked); | 
|  | }, | 
|  | }), | 
|  | value)); | 
|  | }))); | 
|  | } | 
|  | } |