xref: /aosp_15_r20/development/tools/winscope/src/app/components/trace_view_component.ts (revision 90c8c64db3049935a07c6143d7fd006e26f8ecca)
1/*
2 * Copyright (C) 2022 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17import {
18  ChangeDetectorRef,
19  Component,
20  ElementRef,
21  Inject,
22  Input,
23  NgZone,
24  SimpleChanges,
25} from '@angular/core';
26import {FormControl, ValidationErrors, Validators} from '@angular/forms';
27import {overlayPanelStyles} from 'app/styles/overlay_panel.styles';
28import {assertDefined} from 'common/assert_utils';
29import {FunctionUtils} from 'common/function_utils';
30import {Store} from 'common/store';
31import {Analytics} from 'logging/analytics';
32import {
33  FilterPresetApplyRequest,
34  FilterPresetSaveRequest,
35  TabbedViewSwitched,
36  WinscopeEvent,
37  WinscopeEventType,
38} from 'messaging/winscope_event';
39import {
40  EmitEvent,
41  WinscopeEventEmitter,
42} from 'messaging/winscope_event_emitter';
43import {WinscopeEventListener} from 'messaging/winscope_event_listener';
44import {TRACE_INFO} from 'trace/trace_info';
45import {TraceType} from 'trace/trace_type';
46import {inlineButtonStyle} from 'viewers/components/styles/clickable_property.styles';
47import {View, Viewer, ViewType} from 'viewers/viewer';
48
49interface Tab {
50  view: View;
51  addedToDom: boolean;
52}
53
54@Component({
55  selector: 'trace-view',
56  template: `
57      <div class="overlay-container">
58      </div>
59      <div class="header-items-wrapper">
60        <div class="trace-tabs-wrapper header-items-wrapper">
61          <nav mat-tab-nav-bar class="tabs-navigation-bar">
62            <a
63                *ngFor="let tab of tabs; last as isLast"
64                mat-tab-link
65                [active]="isCurrentActiveTab(tab)"
66                [class.active]="isCurrentActiveTab(tab)"
67                [matTooltip]="getTabTooltip(tab.view)"
68                matTooltipPosition="above"
69                [matTooltipShowDelay]="300"
70                (click)="onTabClick(tab)"
71                (focus)="$event.target.blur()"
72                [class.last]="isLast"
73                class="tab">
74              <mat-icon
75                class="icon"
76                [style]="{color: getTabIconColor(tab), marginRight: '0.5rem'}">
77                  {{ getTabIcon(tab) }}
78              </mat-icon>
79              <span>
80                {{ getTitle(tab.view) }}
81              </span>
82            </a>
83          </nav>
84        </div>
85
86        <button
87          [disabled]="!currentTabHasFilterPresets()"
88          mat-flat-button
89          cdkOverlayOrigin
90          #filterPresetsTrigger="cdkOverlayOrigin"
91          color="primary"
92          class="filter-presets"
93          (click)="onFilterPresetsClick()">
94          <span class="filter-presets-label">
95            <mat-icon class="material-symbols-outlined">save</mat-icon>
96            <span> Filter Presets </span>
97          </span>
98        </button>
99
100        <ng-template
101          cdkConnectedOverlay
102          [cdkConnectedOverlayOrigin]="filterPresetsTrigger"
103          [cdkConnectedOverlayOpen]="isFilterPresetsPanelOpen"
104          [cdkConnectedOverlayHasBackdrop]="true"
105          cdkConnectedOverlayBackdropClass="cdk-overlay-transparent-backdrop"
106          (backdropClick)="onFilterPresetsClick()"
107        >
108          <div class="overlay-panel filter-presets-panel">
109            <h2 class="overlay-panel-title">
110              <span> FILTER PRESETS </span>
111              <button (click)="onFilterPresetsClick()" class="close-button" mat-icon-button>
112                <mat-icon> close </mat-icon>
113              </button>
114            </h2>
115            <div class="overlay-panel-content">
116              <span class="mat-body-1"> Save the current configuration of filters for this trace type to access later, or select one of the existing configurations below. </span>
117
118              <div class="overlay-panel-section save-section">
119                <span class="mat-body-2 overlay-panel-section-title"> Preset Name </span>
120                <div class="save-field outline-field">
121                  <mat-form-field appearance="outline">
122                    <input matInput [formControl]="filterPresetNameControl" (keydown.enter)="savePreset()"/>
123                    <mat-error *ngIf="filterPresetNameControl.invalid && filterPresetNameControl.value">Preset with that name already exists.</mat-error>
124                  </mat-form-field>
125                  <button mat-flat-button color="primary" [disabled]="filterPresetNameControl.invalid" (click)="savePreset()"> Save </button>
126                </div>
127              </div>
128
129              <mat-divider></mat-divider>
130
131              <div class="overlay-panel-section existing-presets-section">
132                <span class="mat-body-2 overlay-panel-section-title"> Apply a preset </span>
133                <span class="mat-body-1" *ngIf="getCurrentFilterPresets().length === 0"> No existing presets found. </span>
134                <div *ngFor="let preset of getCurrentFilterPresets()" class="existing-preset inline">
135                  <button
136                      mat-button
137                      color="primary"
138                      (click)="onExistingPresetClick(preset)">
139                    {{ preset.split(".")[0] }}
140                  </button>
141                  <button mat-icon-button class="delete-button" (click)="deletePreset(preset)">
142                    <mat-icon class="material-symbols-outlined"> delete </mat-icon>
143                  </button>
144                </div>
145              </div>
146            </div>
147          </div>
148        </ng-template>
149      </div>
150      <mat-divider></mat-divider>
151      <div class="trace-view-content"></div>
152  `,
153  styles: [
154    `
155      .tab.active {
156        opacity: 100%;
157      }
158
159      .header-items-wrapper {
160        display: flex;
161        flex-direction: row;
162        justify-content: space-between;
163        align-items: center;
164      }
165
166      .trace-tabs-wrapper {
167        overflow-x: auto;
168      }
169
170      .tabs-navigation-bar {
171        height: 100%;
172        border-bottom: 0px;
173      }
174
175      .trace-view-content {
176        height: 100%;
177        overflow: auto;
178        background-color: var(--trace-view-background-color);
179      }
180
181      .tab {
182        overflow-x: hidden;
183        text-overflow: ellipsis;
184      }
185
186      .tab:not(.last):after {
187        content: '';
188        position: absolute;
189        right: 0;
190        height: 60%;
191        width: 1px;
192        background-color: #C4C0C0;
193      }
194
195      .filter-presets {
196        line-height: 24px;
197        padding: 0 10px;
198        margin-inline: 10px;
199        min-width: fit-content;
200        min-height: fit-content;
201      }
202
203      .filter-presets-label {
204        display: flex;
205        flex-direction: row;
206        align-items: center;
207      }
208
209      .filter-presets-label .mat-icon {
210        margin-inline-end: 5px;
211      }
212
213      .filter-presets-panel {
214        max-width: 440px;
215        max-height: 500px;
216        overflow-y: auto;
217        border-radius: 15px;
218      }
219
220      .existing-preset {
221        display: flex;
222        flex-direction: row;
223        justify-content: space-between;
224        align-items: center;
225        width: 100%:
226      }
227
228      .existing-preset:hover {
229        background-color: var(--hover-element-color);
230      }
231
232      .existing-preset:not(:hover) .delete-button {
233        opacity: 0.5;
234      }
235    `,
236    overlayPanelStyles,
237    inlineButtonStyle,
238  ],
239})
240export class TraceViewComponent
241  implements WinscopeEventEmitter, WinscopeEventListener
242{
243  @Input() viewers: Viewer[] = [];
244  @Input() store: Store | undefined;
245
246  TRACE_INFO = TRACE_INFO;
247  tabs: Tab[] = [];
248  isFilterPresetsPanelOpen = false;
249  filterPresetNameControl = new FormControl(
250    '',
251    assertDefined(
252      Validators.compose([
253        Validators.required,
254        (control: FormControl) =>
255          this.validateFilterPresetName(
256            control,
257            this.allFilterPresets,
258            (input: string) =>
259              this.makeFilterPresetName(
260                input,
261                assertDefined(this.getCurrentTabTraceType()),
262              ),
263          ),
264      ]),
265    ),
266  );
267
268  private currentActiveTab: undefined | Tab;
269  private emitAppEvent: EmitEvent = FunctionUtils.DO_NOTHING_ASYNC;
270  private filterPresetsStoreKey = 'filterPresets';
271  private allFilterPresets: string[] = [];
272
273  constructor(
274    @Inject(ElementRef) private elementRef: ElementRef,
275    @Inject(ChangeDetectorRef) private changeDetectorRef: ChangeDetectorRef,
276    @Inject(NgZone) private ngZone: NgZone,
277  ) {}
278
279  ngOnChanges(changes: SimpleChanges) {
280    if (changes['store']?.firstChange) {
281      const storedPresets = this.store?.get(this.filterPresetsStoreKey);
282      if (storedPresets) {
283        this.allFilterPresets = JSON.parse(storedPresets);
284      }
285    }
286    this.renderViewsTab(changes['viewers']?.firstChange ?? false);
287    this.renderViewsOverlay();
288  }
289
290  getTabIconColor(tab: Tab): string {
291    if (tab.view.type === ViewType.GLOBAL_SEARCH) return '';
292    const trace = tab.view.traces.at(0);
293    if (!trace) return '';
294    return TRACE_INFO[trace.type].color;
295  }
296
297  getTabIcon(tab: Tab): string {
298    if (tab.view.type === ViewType.GLOBAL_SEARCH) {
299      return TRACE_INFO[TraceType.SEARCH].icon;
300    }
301    const trace = tab.view.traces.at(0);
302    if (!trace) return '';
303    return TRACE_INFO[trace.type].icon;
304  }
305
306  async onTabClick(tab: Tab) {
307    await this.showTab(tab, false);
308  }
309
310  async onWinscopeEvent(event: WinscopeEvent) {
311    await event.visit(
312      WinscopeEventType.TABBED_VIEW_SWITCH_REQUEST,
313      async (event) => {
314        const tab = this.tabs.find((tab) =>
315          tab.view.traces.some((trace) => trace === event.newActiveTrace),
316        );
317        await this.showTab(assertDefined(tab), false);
318      },
319    );
320  }
321
322  setEmitEvent(callback: EmitEvent) {
323    this.emitAppEvent = callback;
324  }
325
326  isCurrentActiveTab(tab: Tab) {
327    return tab === this.currentActiveTab;
328  }
329
330  getTabTooltip(view: View): string {
331    return view.traces.flatMap((trace) => trace.getDescriptors()).join(', ');
332  }
333
334  getTitle(view: View): string {
335    const isDump = view.traces.length === 1 && view.traces.at(0)?.isDump();
336    return view.title + (isDump ? ' Dump' : '');
337  }
338
339  getCurrentFilterPresets(): string[] {
340    const currentTabTraceType = this.getCurrentTabTraceType();
341    if (currentTabTraceType === undefined) return [];
342    return this.allFilterPresets.filter((preset) =>
343      preset.includes(TRACE_INFO[currentTabTraceType].name),
344    );
345  }
346
347  onFilterPresetsClick() {
348    this.ngZone.run(() => {
349      this.isFilterPresetsPanelOpen = !this.isFilterPresetsPanelOpen;
350      this.changeDetectorRef.detectChanges();
351    });
352  }
353
354  async savePreset() {
355    if (this.filterPresetNameControl.invalid) return;
356    await this.ngZone.run(async () => {
357      const value = assertDefined(this.filterPresetNameControl.value);
358      const currentTabTraceType = assertDefined(this.getCurrentTabTraceType());
359      const presetName = this.makeFilterPresetName(value, currentTabTraceType);
360
361      this.allFilterPresets.push(presetName);
362      if (this.store) {
363        this.store?.add(
364          this.filterPresetsStoreKey,
365          JSON.stringify(this.allFilterPresets),
366        );
367      }
368
369      this.filterPresetNameControl.reset();
370      this.changeDetectorRef.detectChanges();
371      await this.emitAppEvent(
372        new FilterPresetSaveRequest(presetName, currentTabTraceType),
373      );
374    });
375  }
376
377  onExistingPresetClick(preset: string) {
378    this.emitAppEvent(
379      new FilterPresetApplyRequest(
380        preset,
381        assertDefined(this.getCurrentTabTraceType()),
382      ),
383    );
384  }
385
386  deletePreset(preset: string) {
387    this.allFilterPresets = this.allFilterPresets.filter((p) => p !== preset);
388    this.store?.clear(preset);
389    this.store?.add(
390      this.filterPresetsStoreKey,
391      JSON.stringify(this.allFilterPresets),
392    );
393    this.filterPresetNameControl.updateValueAndValidity();
394    this.changeDetectorRef.detectChanges();
395  }
396
397  currentTabHasFilterPresets(): boolean {
398    const currentTabTraceType = this.getCurrentTabTraceType();
399    return (
400      currentTabTraceType !== undefined &&
401      [
402        TraceType.SURFACE_FLINGER,
403        TraceType.WINDOW_MANAGER,
404        TraceType.INPUT_METHOD_CLIENTS,
405        TraceType.INPUT_METHOD_MANAGER_SERVICE,
406        TraceType.INPUT_METHOD_SERVICE,
407        TraceType.VIEW_CAPTURE,
408      ].includes(currentTabTraceType)
409    );
410  }
411
412  private getCurrentTabTraceType(): TraceType | undefined {
413    return this.currentActiveTab?.view.traces.at(0)?.type;
414  }
415
416  private renderViewsTab(firstToRender: boolean) {
417    this.tabs = this.viewers
418      .map((viewer) => viewer.getViews())
419      .flat()
420      .filter((view) => view.type !== ViewType.OVERLAY)
421      .map((view) => {
422        return {
423          view,
424          addedToDom: false,
425        };
426      });
427
428    this.tabs.forEach((tab) => {
429      // TODO: setting "store" this way is a hack.
430      //       Store should be part of View's interface.
431      (tab.view.htmlElement as any).store = this.store;
432    });
433
434    if (this.tabs.length > 0) {
435      const tabToShow = assertDefined(
436        this.tabs.find((tab) => tab.view.type !== ViewType.GLOBAL_SEARCH),
437      );
438      this.showTab(tabToShow, firstToRender);
439    }
440  }
441
442  private renderViewsOverlay() {
443    const views: View[] = this.viewers
444      .map((viewer) => viewer.getViews())
445      .flat()
446      .filter((view) => view.type === ViewType.OVERLAY);
447
448    if (views.length > 1) {
449      throw new Error(
450        'Only one overlay view is supported. To allow more overlay views, either create more than' +
451          ' one draggable containers in this component or move the cdkDrag directives into the' +
452          " overlay view when the new Angular's directive composition API is available" +
453          ' (https://github.com/angular/angular/issues/8785).',
454      );
455    }
456
457    views.forEach((view) => {
458      view.htmlElement.style.pointerEvents = 'all';
459      const container = assertDefined(
460        this.elementRef.nativeElement.querySelector('.overlay-container'),
461      );
462      container.appendChild(view.htmlElement);
463    });
464  }
465
466  private async showTab(tab: Tab, firstToRender: boolean) {
467    if (this.currentActiveTab) {
468      this.currentActiveTab.view.htmlElement.style.display = 'none';
469    }
470
471    if (!tab.addedToDom) {
472      // Workaround for b/255966194:
473      // make sure that the first time a tab content is rendered
474      // (added to the DOM) it has style.display == "". This fixes the
475      // initialization/rendering issues with cdk-virtual-scroll-viewport
476      // components inside the tab contents.
477      const traceViewContent = assertDefined(
478        this.elementRef.nativeElement.querySelector('.trace-view-content'),
479      );
480      traceViewContent.appendChild(tab.view.htmlElement);
481      tab.addedToDom = true;
482    } else {
483      tab.view.htmlElement.style.display = '';
484    }
485
486    this.currentActiveTab = tab;
487
488    if (!firstToRender) {
489      Analytics.Navigation.logTabSwitched(tab.view.title);
490      await this.emitAppEvent(new TabbedViewSwitched(tab.view));
491    }
492  }
493
494  private validateFilterPresetName(
495    control: FormControl,
496    filterPresets: string[],
497    makeFilterPresetName: (input: string) => string,
498  ): ValidationErrors | null {
499    const valid =
500      control.value &&
501      !filterPresets.includes(makeFilterPresetName(control.value));
502    return !valid ? {invalidInput: control.value} : null;
503  }
504
505  private makeFilterPresetName(input: string, traceType: TraceType) {
506    return input + '.' + TRACE_INFO[traceType].name;
507  }
508}
509