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