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