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