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