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