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