xref: /aosp_15_r20/development/tools/winscope/src/viewers/components/tree_component.ts (revision 90c8c64db3049935a07c6143d7fd006e26f8ecca)
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.child-hover]="childHover"
53      [class.full-opacity]="showFullOpacity(node)"
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  childHover = false;
122  readonly levelOffset = 24;
123  nodeElement: HTMLElement;
124
125  private storeKeyCollapsedState = '';
126
127  childTrackById(
128    index: number,
129    child: UiPropertyTreeNode | UiHierarchyTreeNode,
130  ): string {
131    return child.id;
132  }
133
134  constructor(@Inject(ElementRef) public elementRef: ElementRef) {
135    this.nodeElement = elementRef.nativeElement.querySelector('.node');
136    this.nodeElement?.addEventListener(
137      'mousedown',
138      this.nodeMouseDownEventListener,
139    );
140    this.nodeElement?.addEventListener(
141      'mouseenter',
142      this.nodeMouseEnterEventListener,
143    );
144    this.nodeElement?.addEventListener(
145      'mouseleave',
146      this.nodeMouseLeaveEventListener,
147    );
148  }
149
150  ngOnChanges(changes: SimpleChanges) {
151    if (changes['node'] && this.node) {
152      if (this.node.isRoot() && !this.store) {
153        this.store = new InMemoryStorage();
154      }
155      this.storeKeyCollapsedState = `${this.node.id}.collapsedState`;
156      if (this.store) {
157        this.setExpandedValue(!this.isCollapsedInStore());
158      } else {
159        this.setExpandedValue(true);
160      }
161    }
162  }
163
164  ngOnDestroy() {
165    this.nodeElement?.removeEventListener(
166      'mousedown',
167      this.nodeMouseDownEventListener,
168    );
169    this.nodeElement?.removeEventListener(
170      'mouseenter',
171      this.nodeMouseEnterEventListener,
172    );
173    this.nodeElement?.removeEventListener(
174      'mouseleave',
175      this.nodeMouseLeaveEventListener,
176    );
177  }
178
179  isLeaf(node?: UiPropertyTreeNode | UiHierarchyTreeNode): boolean {
180    if (node === undefined) return true;
181    if (node instanceof UiHierarchyTreeNode) {
182      return node.getAllChildren().length === 0;
183    }
184    return (
185      node.formattedValue().length > 0 || node.getAllChildren().length === 0
186    );
187  }
188
189  onNodeClick(event: MouseEvent) {
190    event.preventDefault();
191    if (window.getSelection()?.type === 'range') {
192      return;
193    }
194
195    const isDoubleClick = event.detail % 2 === 0;
196    if (!this.isFlattened && !this.isLeaf(this.node) && isDoubleClick) {
197      event.preventDefault();
198      this.toggleTree();
199    } else {
200      this.updateHighlightedItem();
201    }
202  }
203
204  nodeOffsetStyle() {
205    const offset = this.levelOffset * this.initialDepth;
206    const gutterOffset = this.addGutter() ? this.levelOffset / 2 : 0;
207    return {
208      marginLeft: '-' + offset + 'px',
209      paddingLeft: offset + gutterOffset + 'px',
210    };
211  }
212
213  isPinned() {
214    if (this.node instanceof UiHierarchyTreeNode) {
215      return this.pinnedItems?.map((item) => item.id).includes(this.node!.id);
216    }
217    return false;
218  }
219
220  propagateNewHighlightedItem(
221    newItem: UiPropertyTreeNode | UiHierarchyTreeNode,
222  ) {
223    this.highlightedChange.emit(newItem);
224  }
225
226  propagateNewPinnedItem(newPinnedItem: UiHierarchyTreeNode) {
227    this.pinnedItemChange.emit(newPinnedItem);
228  }
229
230  isClickable() {
231    return !this.isLeaf(this.node) || this.itemsClickable;
232  }
233
234  toggleTree() {
235    this.setExpandedValue(!this.isExpanded());
236  }
237
238  expandTree() {
239    this.setExpandedValue(true);
240  }
241
242  isExpanded() {
243    if (this.isLeaf(this.node)) {
244      return true;
245    }
246
247    if (this.useStoredExpandedState && this.store) {
248      return !this.isCollapsedInStore();
249    }
250
251    return this.localExpandedState;
252  }
253
254  hasSelectedChild() {
255    if (this.isLeaf(this.node)) {
256      return false;
257    }
258    for (const child of assertDefined(this.node).getAllChildren()) {
259      if (this.highlightedItem === child.id) {
260        return true;
261      }
262    }
263    return false;
264  }
265
266  getShowStateIcon(
267    node: UiPropertyTreeNode | UiHierarchyTreeNode,
268  ): string | undefined {
269    const showState = this.rectIdToShowState?.get(node.id);
270    if (showState === undefined || node instanceof UiPropertyTreeNode) {
271      return undefined;
272    }
273    return showState === RectShowState.SHOW ? 'visibility' : 'visibility_off';
274  }
275
276  showFullOpacity(node: UiPropertyTreeNode | UiHierarchyTreeNode) {
277    if (node instanceof UiPropertyTreeNode) return true;
278    if (this.rectIdToShowState === undefined) return true;
279    const showState = this.rectIdToShowState.get(node.id);
280    return showState === RectShowState.SHOW;
281  }
282
283  toggleRectShowState() {
284    const nodeId = assertDefined(this.node).id;
285    const currentShowState = assertDefined(this.rectIdToShowState?.get(nodeId));
286    const newShowState =
287      currentShowState === RectShowState.HIDE
288        ? RectShowState.SHOW
289        : RectShowState.HIDE;
290    const event = new CustomEvent(ViewerEvents.RectShowStateChange, {
291      bubbles: true,
292      detail: {rectId: nodeId, state: newShowState},
293    });
294    this.elementRef.nativeElement.dispatchEvent(event);
295  }
296
297  addGutter() {
298    return (this.rectIdToShowState?.size ?? 0) > 0;
299  }
300
301  private updateHighlightedItem() {
302    if (this.node) this.highlightedChange.emit(this.node);
303  }
304
305  private setExpandedValue(
306    isExpanded: boolean,
307    shouldUpdateStoredState = true,
308  ) {
309    if (this.store && this.useStoredExpandedState && shouldUpdateStoredState) {
310      if (isExpanded) {
311        this.store.clear(this.storeKeyCollapsedState);
312      } else {
313        this.store.add(this.storeKeyCollapsedState, 'true');
314      }
315    } else {
316      this.localExpandedState = isExpanded;
317    }
318  }
319
320  private nodeMouseDownEventListener = (event: MouseEvent) => {
321    if (event.detail > 1) {
322      event.preventDefault();
323      return false;
324    }
325    return true;
326  };
327
328  private nodeMouseEnterEventListener = () => {
329    this.hoverStart.emit();
330  };
331
332  private nodeMouseLeaveEventListener = () => {
333    this.hoverEnd.emit();
334  };
335
336  private isCollapsedInStore(): boolean {
337    return (
338      assertDefined(this.store).get(this.storeKeyCollapsedState) === 'true'
339    );
340  }
341}
342