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