blob: 90f3c3d3ad5098aa2794dfba2249db2e951f67ef [file] [log] [blame]
// 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 m from 'mithril';
import {copyToClipboard} from '../base/clipboard';
import {assertExists} from '../base/logging';
import {RecordConfig} from '../controller/record_config_types';
import {raf} from '../core/raf_scheduler';
import {assetSrc} from '../base/assets';
export declare type Setter<T> = (cfg: RecordConfig, val: T) => void;
export 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 {
recCfg: RecordConfig;
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) => {
attrs.setEnabled(attrs.recCfg, enabled);
raf.scheduleFullRedraw();
};
const enabled = attrs.isEnabled(attrs.recCfg);
return m(
`.probe${attrs.compact ? '.compact' : ''}${enabled ? '.enabled' : ''}`,
attrs.img &&
m('img', {
src: assetSrc(`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${attrs.img ? '' : '.extended-desc'}`,
m('div', attrs.descr),
m('.probe-config', children),
),
);
}
}
export function CompactProbe(args: {
recCfg: RecordConfig;
title: string;
isEnabled: Getter<boolean>;
setEnabled: Setter<boolean>;
}) {
return m(Probe, {
recCfg: args.recCfg,
title: args.title,
img: null,
compact: true,
descr: '',
isEnabled: args.isEnabled,
setEnabled: args.setEnabled,
});
}
// +-------------------------------------------------------------+
// | Toggle: an on/off switch.
// +-------------------------------------------------------------+
export interface ToggleAttrs {
recCfg: RecordConfig;
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) => {
attrs.setEnabled(attrs.recCfg, enabled);
raf.scheduleFullRedraw();
};
const enabled = attrs.isEnabled(attrs.recCfg);
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 {
recCfg: RecordConfig;
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) {
attrs.set(attrs.recCfg, newVal);
raf.scheduleFullRedraw();
}
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]/gim, '_').toLowerCase();
const maxIdx = attrs.values.length - 1;
const val = attrs.get(attrs.recCfg);
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
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 {
recCfg: RecordConfig;
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);
}
attrs.set(attrs.recCfg, selKeys);
raf.scheduleFullRedraw();
}
view({attrs}: m.CVnode<DropdownAttrs>) {
const options: m.Children = [];
const selItems = attrs.get(attrs.recCfg);
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 {
recCfg: RecordConfig;
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) {
attrs.set(attrs.recCfg, dom.value);
raf.scheduleFullRedraw();
}
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(attrs.recCfg),
}),
);
}
}
// +---------------------------------------------------------------------------+
// | 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),
);
}
}
export interface CategoryGetter {
get: Getter<string[]>;
set: Setter<string[]>;
}
type CategoriesCheckboxListParams = CategoryGetter & {
recCfg: RecordConfig;
categories: Map<string, string>;
title: string;
};
export class CategoriesCheckboxList
implements m.ClassComponent<CategoriesCheckboxListParams>
{
updateValue(
attrs: CategoriesCheckboxListParams,
value: string,
enabled: boolean,
) {
const values = attrs.get(attrs.recCfg);
const index = values.indexOf(value);
if (enabled && index === -1) {
values.push(value);
}
if (!enabled && index !== -1) {
values.splice(index, 1);
}
raf.scheduleFullRedraw();
}
view({attrs}: m.CVnode<CategoriesCheckboxListParams>) {
const enabled = new Set(attrs.get(attrs.recCfg));
return m(
'.categories-list',
m(
'h3',
attrs.title,
m(
'button.config-button',
{
onclick: () => {
attrs.set(attrs.recCfg, Array.from(attrs.categories.keys()));
},
},
'All',
),
m(
'button.config-button',
{
onclick: () => {
attrs.set(attrs.recCfg, []);
},
},
'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,
),
);
}),
),
);
}
}