• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1/*
2 * Copyright (C) 2024 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 */
16import {
17  ChangeDetectionStrategy,
18  ChangeDetectorRef,
19  Component,
20  ElementRef,
21  EventEmitter,
22  Inject,
23  Input,
24  Output,
25  SimpleChanges,
26} from '@angular/core';
27import {assertDefined} from 'common/assert_utils';
28import {InMemoryStorage} from 'common/store/in_memory_storage';
29import {RectShowState} from 'viewers/common/rect_show_state';
30import {UiHierarchyTreeNode} from 'viewers/common/ui_hierarchy_tree_node';
31import {UiPropertyTreeNode} from 'viewers/common/ui_property_tree_node';
32import {UiTreeUtils} from 'viewers/common/ui_tree_utils';
33import {ViewerEvents} from 'viewers/common/viewer_events';
34import {
35  nodeInnerItemStyles,
36  nodeStyles,
37  treeNodeDataViewStyles,
38} from 'viewers/components/styles/node.styles';
39
40@Component({
41  selector: 'tree-view',
42  changeDetection: ChangeDetectionStrategy.OnPush,
43  template: `
44    <tree-node
45      *ngIf="node && showNode(node)"
46      [id]="'node' + node.name"
47      class="node"
48      [id]="'node' + node.name"
49      [class.leaf]="isLeaf(node)"
50      [class.selected]="isHighlighted(node, highlightedItem)"
51      [class.clickable]="isClickable()"
52      [class.child-selected]="hasSelectedChild()"
53      [class.child-hover]="childHover"
54      [class.full-opacity]="showFullOpacity(node)"
55      [class]="node.getDiff()"
56      [style]="nodeOffsetStyle()"
57      [node]="node"
58      [flattened]="isFlattened"
59      [isLeaf]="isLeaf(node)"
60      [isExpanded]="isExpanded()"
61      [isPinned]="isPinned()"
62      [isSelected]="isHighlighted(node, highlightedItem)"
63      [showStateIcon]="getShowStateIcon(node)"
64      (toggleTreeChange)="toggleTree()"
65      (rectShowStateChange)="toggleRectShowState()"
66      (click)="onNodeClick($event)"
67      (expandTreeChange)="expandTree()"
68      (pinNodeChange)="propagateNewPinnedItem($event)"></tree-node>
69
70    <div
71      *ngIf="!isLeaf(node)"
72      class="children"
73      [class.flattened]="isFlattened"
74      [class.with-gutter]="addGutter()"
75      [hidden]="!isExpanded()">
76      <tree-view
77        *ngFor="let child of node.children.values(); trackBy: childTrackById"
78        class="subtree"
79        [node]="child"
80        [store]="store"
81        [showNode]="showNode"
82        [isFlattened]="isFlattened"
83        [useStoredExpandedState]="useStoredExpandedState"
84        [initialDepth]="initialDepth + 1"
85        [highlightedItem]="highlightedItem"
86        [pinnedItems]="pinnedItems"
87        [itemsClickable]="itemsClickable"
88        [rectIdToShowState]="rectIdToShowState"
89        (highlightedChange)="propagateNewHighlightedItem($event)"
90        (pinnedItemChange)="propagateNewPinnedItem($event)"
91        (hoverStart)="childHover = true"
92        (hoverEnd)="childHover = false"
93        (expandParent)="expandTree()"></tree-view>
94    </div>
95  `,
96  styles: [nodeStyles, treeNodeDataViewStyles, nodeInnerItemStyles],
97})
98export class TreeComponent {
99  isHighlighted = UiTreeUtils.isHighlighted;
100
101  @Input() node?: UiPropertyTreeNode | UiHierarchyTreeNode;
102  @Input() store: InMemoryStorage | undefined;
103  @Input() isFlattened? = false;
104  @Input() initialDepth = 0;
105  @Input() highlightedItem = '';
106  @Input() pinnedItems?: UiHierarchyTreeNode[] = [];
107  @Input() itemsClickable?: boolean;
108  @Input() rectIdToShowState?: Map<string, RectShowState>;
109
110  // Conditionally use stored states. Some traces (e.g. transactions) do not provide items with the "stable id" field needed to search values in the storage.
111  @Input() useStoredExpandedState = false;
112
113  @Input() showNode = (node: UiPropertyTreeNode | UiHierarchyTreeNode) => true;
114
115  @Output() readonly highlightedChange = new EventEmitter<
116    UiHierarchyTreeNode | UiPropertyTreeNode
117  >();
118  @Output() readonly pinnedItemChange = new EventEmitter<UiHierarchyTreeNode>();
119  @Output() readonly hoverStart = new EventEmitter<void>();
120  @Output() readonly hoverEnd = new EventEmitter<void>();
121  @Output() readonly expandParent = new EventEmitter<void>();
122
123  childHover = false;
124  readonly levelOffset = 24;
125  nodeElement: HTMLElement;
126
127  private localExpandedState = true;
128  private storeKeyCollapsedState = '';
129
130  childTrackById(
131    index: number,
132    child: UiPropertyTreeNode | UiHierarchyTreeNode,
133  ): string {
134    return child.id;
135  }
136
137  constructor(
138    @Inject(ElementRef) public elementRef: ElementRef,
139    @Inject(ChangeDetectorRef) private changeDetectorRef: ChangeDetectorRef,
140  ) {
141    this.nodeElement = elementRef.nativeElement.querySelector('.node');
142    this.nodeElement?.addEventListener(
143      'mousedown',
144      this.nodeMouseDownEventListener,
145    );
146    this.nodeElement?.addEventListener(
147      'mouseenter',
148      this.nodeMouseEnterEventListener,
149    );
150    this.nodeElement?.addEventListener(
151      'mouseleave',
152      this.nodeMouseLeaveEventListener,
153    );
154  }
155
156  ngOnChanges(changes: SimpleChanges) {
157    if (changes['node'] && this.node) {
158      if (this.node.isRoot() && !this.store) {
159        this.store = new InMemoryStorage();
160      }
161      this.storeKeyCollapsedState = `${this.node.id}.collapsedState`;
162      if (this.store) {
163        this.setExpandedValue(!this.isCollapsedInStore());
164      } else {
165        this.setExpandedValue(true);
166      }
167    }
168  }
169
170  ngOnDestroy() {
171    this.nodeElement?.removeEventListener(
172      'mousedown',
173      this.nodeMouseDownEventListener,
174    );
175    this.nodeElement?.removeEventListener(
176      'mouseenter',
177      this.nodeMouseEnterEventListener,
178    );
179    this.nodeElement?.removeEventListener(
180      'mouseleave',
181      this.nodeMouseLeaveEventListener,
182    );
183  }
184
185  isLeaf(node?: UiPropertyTreeNode | UiHierarchyTreeNode): boolean {
186    if (node === undefined) return true;
187    if (node instanceof UiHierarchyTreeNode) {
188      return node.getAllChildren().length === 0;
189    }
190    return (
191      node.formattedValue().length > 0 || node.getAllChildren().length === 0
192    );
193  }
194
195  onNodeClick(event: MouseEvent) {
196    event.preventDefault();
197    if (window.getSelection()?.type === 'range') {
198      return;
199    }
200
201    const isDoubleClick = event.detail % 2 === 0;
202    if (!this.isFlattened && !this.isLeaf(this.node) && isDoubleClick) {
203      event.preventDefault();
204      this.toggleTree();
205    } else {
206      this.updateHighlightedItem();
207    }
208  }
209
210  nodeOffsetStyle() {
211    const offset = this.levelOffset * this.initialDepth;
212    const gutterOffset = this.addGutter() ? this.levelOffset / 2 : 0;
213    return {
214      marginLeft: '-' + offset + 'px',
215      paddingLeft: offset + gutterOffset + 'px',
216    };
217  }
218
219  isPinned() {
220    if (this.node instanceof UiHierarchyTreeNode) {
221      return this.pinnedItems?.map((item) => item.id).includes(this.node!.id);
222    }
223    return false;
224  }
225
226  propagateNewHighlightedItem(
227    newItem: UiPropertyTreeNode | UiHierarchyTreeNode,
228  ) {
229    this.highlightedChange.emit(newItem);
230  }
231
232  propagateNewPinnedItem(newPinnedItem: UiHierarchyTreeNode) {
233    this.pinnedItemChange.emit(newPinnedItem);
234  }
235
236  isClickable() {
237    return !this.isLeaf(this.node) || this.itemsClickable;
238  }
239
240  toggleTree() {
241    this.setExpandedValue(!this.isExpanded());
242  }
243
244  expandTree() {
245    this.setExpandedValue(true);
246    this.changeDetectorRef.detectChanges();
247    this.expandParent.emit();
248  }
249
250  isExpanded() {
251    if (this.isLeaf(this.node)) {
252      return true;
253    }
254
255    if (this.useStoredExpandedState && this.store) {
256      return !this.isCollapsedInStore();
257    }
258
259    return this.localExpandedState;
260  }
261
262  hasSelectedChild() {
263    if (this.isLeaf(this.node)) {
264      return false;
265    }
266    for (const child of assertDefined(this.node).getAllChildren()) {
267      if (this.highlightedItem === child.id) {
268        return true;
269      }
270    }
271    return false;
272  }
273
274  getShowStateIcon(
275    node: UiPropertyTreeNode | UiHierarchyTreeNode,
276  ): string | undefined {
277    const showState = this.rectIdToShowState?.get(node.id);
278    if (showState === undefined || node instanceof UiPropertyTreeNode) {
279      return undefined;
280    }
281    return showState === RectShowState.SHOW ? 'visibility' : 'visibility_off';
282  }
283
284  showFullOpacity(node: UiPropertyTreeNode | UiHierarchyTreeNode) {
285    if (node instanceof UiPropertyTreeNode) return true;
286    if (this.rectIdToShowState === undefined) return true;
287    const showState = this.rectIdToShowState.get(node.id);
288    return showState === RectShowState.SHOW;
289  }
290
291  toggleRectShowState() {
292    const nodeId = assertDefined(this.node).id;
293    const currentShowState = assertDefined(this.rectIdToShowState?.get(nodeId));
294    const newShowState =
295      currentShowState === RectShowState.HIDE
296        ? RectShowState.SHOW
297        : RectShowState.HIDE;
298    const event = new CustomEvent(ViewerEvents.RectShowStateChange, {
299      bubbles: true,
300      detail: {rectId: nodeId, state: newShowState},
301    });
302    this.elementRef.nativeElement.dispatchEvent(event);
303  }
304
305  addGutter() {
306    return (this.rectIdToShowState?.size ?? 0) > 0;
307  }
308
309  private updateHighlightedItem() {
310    if (this.node) this.highlightedChange.emit(this.node);
311  }
312
313  private setExpandedValue(
314    isExpanded: boolean,
315    shouldUpdateStoredState = true,
316  ) {
317    if (this.store && this.useStoredExpandedState && shouldUpdateStoredState) {
318      if (isExpanded) {
319        this.store.clear(this.storeKeyCollapsedState);
320      } else {
321        this.store.add(this.storeKeyCollapsedState, 'true');
322      }
323    } else {
324      this.localExpandedState = isExpanded;
325    }
326  }
327
328  private nodeMouseDownEventListener = (event: MouseEvent) => {
329    if (event.detail > 1) {
330      event.preventDefault();
331      return false;
332    }
333    return true;
334  };
335
336  private nodeMouseEnterEventListener = () => {
337    this.hoverStart.emit();
338  };
339
340  private nodeMouseLeaveEventListener = () => {
341    this.hoverEnd.emit();
342  };
343
344  private isCollapsedInStore(): boolean {
345    return (
346      assertDefined(this.store).get(this.storeKeyCollapsedState) !== undefined
347    );
348  }
349}
350