blob: 5e193c7f4166588aae6b9e3fd747c203bafdab1e [file] [log] [blame]
/*
* Copyright (C) 2015 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.
*/
package com.android.tools.idea.uibuilder.structure;
import com.android.SdkConstants;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.tools.idea.uibuilder.model.*;
import com.android.tools.idea.uibuilder.surface.DesignSurface;
import com.android.tools.idea.uibuilder.surface.DesignSurfaceListener;
import com.android.tools.idea.uibuilder.surface.ScreenView;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.util.Disposer;
import com.intellij.ui.ColoredTreeCellRenderer;
import com.intellij.ui.treeStructure.Tree;
import com.intellij.util.IJSwingUtilities;
import com.intellij.util.ui.UIUtil;
import com.intellij.util.ui.tree.TreeUtil;
import com.intellij.util.ui.update.MergingUpdateQueue;
import com.intellij.util.ui.update.Update;
import javax.swing.*;
import javax.swing.border.EmptyBorder;
import javax.swing.event.TreeSelectionEvent;
import javax.swing.event.TreeSelectionListener;
import javax.swing.tree.*;
import java.awt.*;
import java.awt.Insets;
import java.util.*;
import java.awt.dnd.DropTarget;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import static com.android.SdkConstants.*;
import static com.android.tools.idea.uibuilder.property.NlPropertiesPanel.UPDATE_DELAY_MSECS;
import static com.android.tools.idea.uibuilder.structure.NlComponentTree.InsertionPoint.INSERT_INTO;
import static com.intellij.util.Alarm.ThreadToUse.SWING_THREAD;
public class NlComponentTree extends Tree implements DesignSurfaceListener, ModelListener, SelectionListener {
private static final Insets INSETS = new Insets(0, 6, 0, 6);
private final StructureTreeDecorator myDecorator;
private final Map<NlComponent, DefaultMutableTreeNode> myComponent2Node;
private final Map<String, DefaultMutableTreeNode> myId2Node;
private final AtomicBoolean mySelectionIsUpdating;
private final MergingUpdateQueue myUpdateQueue;
private ScreenView myScreenView;
private NlModel myModel;
private boolean myWasExpanded;
private TreePath myInsertionPath;
private InsertionPoint myInsertionPoint;
public NlComponentTree(@NonNull DesignSurface designSurface) {
myDecorator = StructureTreeDecorator.get();
myComponent2Node = new HashMap<NlComponent, DefaultMutableTreeNode>();
myId2Node = new HashMap<String, DefaultMutableTreeNode>();
mySelectionIsUpdating = new AtomicBoolean(false);
myUpdateQueue = new MergingUpdateQueue(
"android.layout.structure-pane", UPDATE_DELAY_MSECS, true, null, null, null, SWING_THREAD);
DefaultMutableTreeNode rootNode = new DefaultMutableTreeNode(null);
DefaultTreeModel treeModel = new DefaultTreeModel(rootNode);
setModel(treeModel);
getSelectionModel().setSelectionMode(TreeSelectionModel.DISCONTIGUOUS_TREE_SELECTION);
setBorder(new EmptyBorder(INSETS));
setRootVisible(false);
setShowsRootHandles(false);
setToggleClickCount(1);
ToolTipManager.sharedInstance().registerComponent(this);
TreeUtil.installActions(this);
createCellRenderer();
addTreeSelectionListener(new StructurePaneSelectionListener());
//todo: new StructureSpeedSearch(myTree);
enableDnD();
setDesignSurface(designSurface);
}
private void enableDnD() {
if (!ApplicationManager.getApplication().isHeadlessEnvironment()) {
setDragEnabled(true);
setTransferHandler(new TreeTransferHandler());
setDropTarget(new DropTarget(this, new NlDropListener(this)));
}
}
public void setDesignSurface(@Nullable DesignSurface designSurface) {
setScreenView(designSurface != null ? designSurface.getCurrentScreenView() : null);
}
private void setScreenView(@Nullable ScreenView screenView) {
myScreenView = screenView;
setModel(screenView != null ? screenView.getModel() : null);
}
@Nullable
public ScreenView getScreenView() {
return myScreenView;
}
private void setModel(@Nullable NlModel model) {
if (myModel != null) {
myModel.removeListener(this);
myModel.getSelectionModel().removeListener(this);
}
myModel = model;
if (myModel != null) {
myModel.addListener(this);
myModel.getSelectionModel().addListener(this);
}
loadData();
}
@Nullable
public NlModel getDesignerModel() {
return myModel;
}
public void dispose() {
if (myModel != null) {
myModel.removeListener(this);
myModel.getSelectionModel().removeListener(this);
myModel = null;
}
Disposer.dispose(myUpdateQueue);
}
private void createCellRenderer() {
ColoredTreeCellRenderer renderer = new ColoredTreeCellRenderer() {
@Override
public void customizeCellRenderer(@NonNull JTree tree,
Object value,
boolean selected,
boolean expanded,
boolean leaf,
int row,
boolean hasFocus) {
DefaultMutableTreeNode node = (DefaultMutableTreeNode)value;
NlComponent component = (NlComponent)node.getUserObject();
if (component == null) {
return;
}
myDecorator.decorate(component, this, true);
}
};
renderer.setBorder(BorderFactory.createEmptyBorder(1, 1, 1, 1));
setCellRenderer(renderer);
}
private void loadData() {
updateHierarchy(true);
}
private void invalidateUI() {
IJSwingUtilities.updateComponentTreeUI(this);
}
// ---- Methods for updating hierarchy while attempting to keep expanded nodes expanded ----
private void updateHierarchy(final boolean firstLoad) {
ApplicationManager.getApplication().assertIsDispatchThread();
setPaintBusy(true);
myUpdateQueue.queue(new Update("updateComponentStructure") {
@Override
public void run() {
try {
mySelectionIsUpdating.set(true);
if (firstLoad) {
myWasExpanded = false;
myId2Node.clear();
}
HierarchyUpdater updater = new HierarchyUpdater();
updater.execute();
expandOnce();
invalidateUI();
}
finally {
setPaintBusy(false);
mySelectionIsUpdating.set(false);
}
if (firstLoad) {
updateSelection();
}
}
});
}
private void expandOnce() {
if (myWasExpanded) {
return;
}
final DefaultMutableTreeNode rootNode = (DefaultMutableTreeNode)getModel().getRoot();
if (rootNode.isLeaf()) {
return;
}
ApplicationManager.getApplication().invokeLater(new Runnable() {
@Override
public void run() {
DefaultMutableTreeNode nodeToExpand = rootNode;
NlComponent component = findComponentToExpandTo();
if (component != null) {
nodeToExpand = myComponent2Node.get(component);
if (nodeToExpand == null) {
nodeToExpand = rootNode;
}
}
TreePath path = new TreePath(nodeToExpand.getPath());
expandPath(path);
while (path != null) {
path = path.getParentPath();
expandPath(path);
}
myWasExpanded = true;
}
});
}
// Find a component that it would be interesting to expand to when a new file is viewed.
// If the file has an App Bar lookup something that may be user content.
@Nullable
private NlComponent findComponentToExpandTo() {
if (myModel == null || myModel.getComponents().isEmpty()) {
return null;
}
NlComponent root = myModel.getComponents().get(0);
NlComponent childOfInterest = root;
if (root.getTagName().equals(COORDINATOR_LAYOUT)) {
// Find first child that is not an AppBarLayout and not anchored to anything.
for (NlComponent child : root.getChildren()) {
if (!child.getTagName().equals(APP_BAR_LAYOUT) && child.getTag().getAttribute(ATTR_LAYOUT_ANCHOR, AUTO_URI) == null) {
// If this is a NestedScrollView look inside:
if (child.getTagName().equals(SdkConstants.CLASS_NESTED_SCROLL_VIEW) && child.children != null && !child.children.isEmpty()) {
child = child.getChild(0);
}
childOfInterest = child;
break;
}
}
}
if (childOfInterest == null || childOfInterest.children == null || childOfInterest.children.isEmpty()) {
return childOfInterest;
}
return childOfInterest.children.get(childOfInterest.children.size() - 1);
}
private void updateSelection() {
if (!mySelectionIsUpdating.compareAndSet(false, true)) {
return;
}
try {
clearSelection();
if (myModel != null) {
for (NlComponent component : myModel.getSelectionModel().getSelection()) {
DefaultMutableTreeNode node = myComponent2Node.get(component);
if (node != null) {
TreePath path = new TreePath(node.getPath());
expandPath(path);
addSelectionPath(path);
}
}
}
} finally {
mySelectionIsUpdating.set(false);
}
}
@Override
public void paint(Graphics g) {
super.paint(g);
if (myInsertionPath != null) {
paintInsertionPoint(g);
}
}
enum InsertionPoint {
INSERT_INTO,
INSERT_BEFORE,
INSERT_AFTER
}
private void paintInsertionPoint(Graphics g) {
if (myInsertionPath != null) {
Rectangle pathBounds = getPathBounds(myInsertionPath);
if (pathBounds == null) {
return;
}
int y = pathBounds.y;
switch (myInsertionPoint) {
case INSERT_BEFORE:
break;
case INSERT_AFTER:
y += pathBounds.height;
break;
case INSERT_INTO:
y += pathBounds.height / 2;
break;
}
Rectangle bounds = getBounds();
Polygon triangle = new Polygon();
triangle.addPoint(bounds.x + 6, y);
triangle.addPoint(bounds.x, y + 3);
triangle.addPoint(bounds.x, y - 3);
g.setColor(UIUtil.getTreeForeground());
if (myInsertionPoint != INSERT_INTO) {
g.drawLine(bounds.x, y, bounds.x + bounds.width, y);
}
g.drawPolygon(triangle);
g.fillPolygon(triangle);
}
}
public void markInsertionPoint(@Nullable TreePath path, @NonNull InsertionPoint insertionPoint) {
if (myInsertionPath != path || myInsertionPoint != insertionPoint) {
myInsertionPath = path;
myInsertionPoint = insertionPoint;
repaint();
}
}
public List<NlComponent> getSelectedComponents() {
List<NlComponent> selected = new ArrayList<NlComponent>();
TreePath[] paths = getSelectionPaths();
if (paths != null) {
for (TreePath path : paths) {
DefaultMutableTreeNode node = (DefaultMutableTreeNode)path.getLastPathComponent();
selected.add((NlComponent)node.getUserObject());
}
}
return selected;
}
// ---- Implemented SelectionListener ----
@Override
public void selectionChanged(@NonNull SelectionModel model, @NonNull List<NlComponent> selection) {
UIUtil.invokeLaterIfNeeded(new Runnable() {
@Override
public void run() {
updateSelection();
}
});
}
// ---- Implemented ModelListener ----
@Override
public void modelChanged(@NonNull NlModel model) {
}
@Override
public void modelRendered(@NonNull NlModel model) {
UIUtil.invokeLaterIfNeeded(new Runnable() {
@Override
public void run() {
updateHierarchy(false);
}
});
}
// ---- Implemented DesignSurfaceListener ----
@Override
public void componentSelectionChanged(@NonNull DesignSurface surface, @NonNull List<NlComponent> newSelection) {
}
@Override
public void screenChanged(@NonNull DesignSurface surface, @Nullable ScreenView screenView) {
setScreenView(screenView);
}
@Override
public void modelChanged(@NonNull DesignSurface surface, @Nullable NlModel model) {
if (model != null) {
modelRendered(model);
}
}
/**
* Updating the tree nodes after the model has changed presents a few problems:
* <ul>
* <li>We would like the current expanded nodes to continue to appear expanded.</li>
* <li>NlComponent and XmlTag instances may have been changed and can no longer be trusted.</li>
* </ul>
* The solution used here is not elegant. The idea is to attempt to restore the visible nodes that have an id.
* We require:
* <ul>
* <li>A mapping from component id to the tree node from the previous update.</li>
* <li>A set of nodes that are currently visible which is computed here from the current tree.</li>
* </ul>
* When we find an old node for a component id we will reuse that node and make sure all parent nodes are expanded
* if this node was visible before the update.
* <br/>
* As a side effect we build the following maps:
* <ul>
* <li>A map from component id to the new tree node (for the next hierarchy update).</li>
* <li>A map from component reference to the new tree node (for handling of selection changes).</li>
* </ul>
*/
private class HierarchyUpdater {
private final Map<String, DefaultMutableTreeNode> myId2TempNode;
private final Set<DefaultMutableTreeNode> myVisibleNodes;
private HierarchyUpdater() {
myId2TempNode = new HashMap<String, DefaultMutableTreeNode>(myId2Node);
myVisibleNodes = new HashSet<DefaultMutableTreeNode>();
}
public void execute() {
myId2Node.clear();
myComponent2Node.clear();
TreePath rootPath = new TreePath(getModel().getRoot());
recordVisibleNodes(rootPath);
List<NlComponent> components = myModel != null ? myModel.getComponents() : null;
replaceChildNodes(rootPath, components);
}
private void recordVisibleNodes(@NonNull TreePath path) {
if (isExpanded(path)) {
DefaultMutableTreeNode node = (DefaultMutableTreeNode)path.getLastPathComponent();
for (int i=0; i<node.getChildCount(); i++) {
DefaultMutableTreeNode child = (DefaultMutableTreeNode)node.getChildAt(i);
recordVisibleNodes(path.pathByAddingChild(child));
myVisibleNodes.add(child);
}
}
}
private void replaceChildNodes(@NonNull TreePath path, @Nullable List<NlComponent> subComponents) {
DefaultMutableTreeNode node = (DefaultMutableTreeNode)path.getLastPathComponent();
node.removeAllChildren();
if (subComponents != null) {
boolean mustExpand = false;
for (NlComponent child : subComponents) {
mustExpand |= addChildNode(path, child);
}
if (mustExpand) {
expandPath(path);
}
}
}
private boolean addChildNode(@NonNull TreePath path, @NonNull NlComponent component) {
DefaultMutableTreeNode parent = (DefaultMutableTreeNode)path.getLastPathComponent();
DefaultMutableTreeNode node = null;
String id = component.getId();
if (id != null && !myId2Node.containsKey(id)) {
node = myId2TempNode.get(id);
}
if (node == null) {
node = new DefaultMutableTreeNode(component);
} else {
node.setUserObject(component);
}
if (id != null) {
myId2Node.put(id, node);
}
myComponent2Node.put(component, node);
parent.add(node);
replaceChildNodes(path.pathByAddingChild(node), component.children);
return myVisibleNodes.contains(node);
}
}
private class StructurePaneSelectionListener implements TreeSelectionListener {
@Override
public void valueChanged(TreeSelectionEvent treeSelectionEvent) {
if (!mySelectionIsUpdating.compareAndSet(false, true)) {
return;
}
try {
myModel.getSelectionModel().setSelection(getSelectedComponents());
} finally {
mySelectionIsUpdating.set(false);
}
}
}
}