/*
* Copyright (C) 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
ChangeDetectionStrategy,
Component,
ElementRef,
EventEmitter,
Inject,
Input,
Output,
SimpleChanges,
} from '@angular/core';
import {assertDefined} from 'common/assert_utils';
import {InMemoryStorage} from 'common/in_memory_storage';
import {RectShowState} from 'viewers/common/rect_show_state';
import {UiHierarchyTreeNode} from 'viewers/common/ui_hierarchy_tree_node';
import {UiPropertyTreeNode} from 'viewers/common/ui_property_tree_node';
import {UiTreeUtils} from 'viewers/common/ui_tree_utils';
import {ViewerEvents} from 'viewers/common/viewer_events';
import {
nodeInnerItemStyles,
nodeStyles,
treeNodeDataViewStyles,
} from 'viewers/components/styles/node.styles';
@Component({
selector: 'tree-view',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
`,
styles: [nodeStyles, treeNodeDataViewStyles, nodeInnerItemStyles],
})
export class TreeComponent {
isHighlighted = UiTreeUtils.isHighlighted;
@Input() node?: UiPropertyTreeNode | UiHierarchyTreeNode;
@Input() store: InMemoryStorage | undefined;
@Input() isFlattened? = false;
@Input() initialDepth = 0;
@Input() highlightedItem = '';
@Input() pinnedItems?: UiHierarchyTreeNode[] = [];
@Input() itemsClickable?: boolean;
@Input() rectIdToShowState?: Map;
// 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.
@Input() useStoredExpandedState = false;
@Input() showNode = (node: UiPropertyTreeNode | UiHierarchyTreeNode) => true;
@Output() readonly highlightedChange = new EventEmitter<
UiHierarchyTreeNode | UiPropertyTreeNode
>();
@Output() readonly pinnedItemChange = new EventEmitter();
@Output() readonly hoverStart = new EventEmitter();
@Output() readonly hoverEnd = new EventEmitter();
localExpandedState = true;
childHover = false;
readonly levelOffset = 24;
nodeElement: HTMLElement;
private storeKeyCollapsedState = '';
childTrackById(
index: number,
child: UiPropertyTreeNode | UiHierarchyTreeNode,
): string {
return child.id;
}
constructor(@Inject(ElementRef) public elementRef: ElementRef) {
this.nodeElement = elementRef.nativeElement.querySelector('.node');
this.nodeElement?.addEventListener(
'mousedown',
this.nodeMouseDownEventListener,
);
this.nodeElement?.addEventListener(
'mouseenter',
this.nodeMouseEnterEventListener,
);
this.nodeElement?.addEventListener(
'mouseleave',
this.nodeMouseLeaveEventListener,
);
}
ngOnChanges(changes: SimpleChanges) {
if (changes['node'] && this.node) {
if (this.node.isRoot() && !this.store) {
this.store = new InMemoryStorage();
}
this.storeKeyCollapsedState = `${this.node.id}.collapsedState`;
if (this.store) {
this.setExpandedValue(!this.isCollapsedInStore());
} else {
this.setExpandedValue(true);
}
}
}
ngOnDestroy() {
this.nodeElement?.removeEventListener(
'mousedown',
this.nodeMouseDownEventListener,
);
this.nodeElement?.removeEventListener(
'mouseenter',
this.nodeMouseEnterEventListener,
);
this.nodeElement?.removeEventListener(
'mouseleave',
this.nodeMouseLeaveEventListener,
);
}
isLeaf(node?: UiPropertyTreeNode | UiHierarchyTreeNode): boolean {
if (node === undefined) return true;
if (node instanceof UiHierarchyTreeNode) {
return node.getAllChildren().length === 0;
}
return (
node.formattedValue().length > 0 || node.getAllChildren().length === 0
);
}
onNodeClick(event: MouseEvent) {
event.preventDefault();
if (window.getSelection()?.type === 'range') {
return;
}
const isDoubleClick = event.detail % 2 === 0;
if (!this.isFlattened && !this.isLeaf(this.node) && isDoubleClick) {
event.preventDefault();
this.toggleTree();
} else {
this.updateHighlightedItem();
}
}
nodeOffsetStyle() {
const offset = this.levelOffset * this.initialDepth;
const gutterOffset = this.addGutter() ? this.levelOffset / 2 : 0;
return {
marginLeft: '-' + offset + 'px',
paddingLeft: offset + gutterOffset + 'px',
};
}
isPinned() {
if (this.node instanceof UiHierarchyTreeNode) {
return this.pinnedItems?.map((item) => item.id).includes(this.node!.id);
}
return false;
}
propagateNewHighlightedItem(
newItem: UiPropertyTreeNode | UiHierarchyTreeNode,
) {
this.highlightedChange.emit(newItem);
}
propagateNewPinnedItem(newPinnedItem: UiHierarchyTreeNode) {
this.pinnedItemChange.emit(newPinnedItem);
}
isClickable() {
return !this.isLeaf(this.node) || this.itemsClickable;
}
toggleTree() {
this.setExpandedValue(!this.isExpanded());
}
expandTree() {
this.setExpandedValue(true);
}
isExpanded() {
if (this.isLeaf(this.node)) {
return true;
}
if (this.useStoredExpandedState && this.store) {
return !this.isCollapsedInStore();
}
return this.localExpandedState;
}
hasSelectedChild() {
if (this.isLeaf(this.node)) {
return false;
}
for (const child of assertDefined(this.node).getAllChildren()) {
if (this.highlightedItem === child.id) {
return true;
}
}
return false;
}
getShowStateIcon(
node: UiPropertyTreeNode | UiHierarchyTreeNode,
): string | undefined {
const showState = this.rectIdToShowState?.get(node.id);
if (showState === undefined || node instanceof UiPropertyTreeNode) {
return undefined;
}
return showState === RectShowState.SHOW ? 'visibility' : 'visibility_off';
}
showFullOpacity(node: UiPropertyTreeNode | UiHierarchyTreeNode) {
if (node instanceof UiPropertyTreeNode) return true;
if (this.rectIdToShowState === undefined) return true;
const showState = this.rectIdToShowState.get(node.id);
return showState === RectShowState.SHOW;
}
toggleRectShowState() {
const nodeId = assertDefined(this.node).id;
const currentShowState = assertDefined(this.rectIdToShowState?.get(nodeId));
const newShowState =
currentShowState === RectShowState.HIDE
? RectShowState.SHOW
: RectShowState.HIDE;
const event = new CustomEvent(ViewerEvents.RectShowStateChange, {
bubbles: true,
detail: {rectId: nodeId, state: newShowState},
});
this.elementRef.nativeElement.dispatchEvent(event);
}
addGutter() {
return (this.rectIdToShowState?.size ?? 0) > 0;
}
private updateHighlightedItem() {
if (this.node) this.highlightedChange.emit(this.node);
}
private setExpandedValue(
isExpanded: boolean,
shouldUpdateStoredState = true,
) {
if (this.store && this.useStoredExpandedState && shouldUpdateStoredState) {
if (isExpanded) {
this.store.clear(this.storeKeyCollapsedState);
} else {
this.store.add(this.storeKeyCollapsedState, 'true');
}
} else {
this.localExpandedState = isExpanded;
}
}
private nodeMouseDownEventListener = (event: MouseEvent) => {
if (event.detail > 1) {
event.preventDefault();
return false;
}
return true;
};
private nodeMouseEnterEventListener = () => {
this.hoverStart.emit();
};
private nodeMouseLeaveEventListener = () => {
this.hoverEnd.emit();
};
private isCollapsedInStore(): boolean {
return (
assertDefined(this.store).get(this.storeKeyCollapsedState) === 'true'
);
}
}