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