/* * Copyright 2003-2015 JetBrains s.r.o. * * 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 jetbrains.mps.ide.ui.tree; import com.intellij.ide.dnd.aware.DnDAwareTree; import com.intellij.openapi.Disposable; import com.intellij.openapi.actionSystem.ActionGroup; import com.intellij.openapi.actionSystem.ActionManager; import com.intellij.openapi.actionSystem.ActionPlaces; import com.intellij.openapi.wm.IdeFocusManager; import com.intellij.ui.TreeUIHelper; import com.intellij.util.ui.tree.WideSelectionTreeUI; import com.intellij.util.ui.update.MergingUpdateQueue; import com.intellij.util.ui.update.Update; import jetbrains.mps.RuntimeFlags; import jetbrains.mps.ide.ThreadUtils; import jetbrains.mps.smodel.ModelAccess; import org.apache.log4j.LogManager; import org.apache.log4j.Logger; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import javax.swing.AbstractAction; import javax.swing.JPopupMenu; import javax.swing.KeyStroke; import javax.swing.ToolTipManager; import javax.swing.event.TreeExpansionEvent; import javax.swing.event.TreeExpansionListener; import javax.swing.event.TreeWillExpandListener; import javax.swing.plaf.TreeUI; import javax.swing.tree.DefaultTreeModel; import javax.swing.tree.ExpandVetoException; import javax.swing.tree.TreeNode; import javax.swing.tree.TreePath; import java.awt.Graphics; import java.awt.Rectangle; import java.awt.event.ActionEvent; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; import java.util.Enumeration; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Set; public abstract class MPSTree extends DnDAwareTree implements Disposable { public static final String PATH = "path"; protected static final Logger LOG = LogManager.getLogger(MPSTree.class); public static final String TREE_PATH_SEPARATOR = "/"; private int myTooltipManagerRecentInitialDelay; private boolean myAutoExpandEnabled = true; private boolean myAutoOpen = false; private boolean myLoadingDisabled; private Set<MPSTreeNode> myExpandingNodes = new HashSet<MPSTreeNode>(); private List<MPSTreeNodeListener> myTreeNodeListeners = new ArrayList<MPSTreeNodeListener>(); // todo: make unique name private MergingUpdateQueue myQueue = new MergingUpdateQueue("MPS Tree Rebuild Later Watcher Queue", 500, true, null); private final Object myUpdateId = new Object(); private boolean myDisposed = false; protected MPSTree() { setRootNode(new TextTreeNode("Empty")); new MPSTreeSpeedSearch(this); ToolTipManager.sharedInstance().registerComponent(this); largeModel = true; TreeUIHelper.getInstance().installToolTipHandler(this); setCellRenderer(new NewMPSTreeCellRenderer()); addTreeWillExpandListener(new MyTreeWillExpandListener()); addTreeExpansionListener(new MyTreeExpansionListener()); addMouseListener(new MyMouseAdapter()); registerKeyboardAction(new MyOpenNodeAction(), KeyStroke.getKeyStroke("F4"), WHEN_ANCESTOR_OF_FOCUSED_COMPONENT); registerKeyboardAction(new MyRefreshAction(), KeyStroke.getKeyStroke("F5"), WHEN_ANCESTOR_OF_FOCUSED_COMPONENT); } /** * Initialization sequence common for each node initialized in the tree. * Shall invoke {@link MPSTreeNode#doInit()} to perform actual initialization, does this through appropriate runnable * supplied to {@link #doInit(MPSTreeNode, Runnable)}}, which is left protected for sub-classes * that may add extra utility stuff, like progress indication. * Primary responsibility is guards to prevent endless initialization */ /*package*/ final void performInit(final MPSTreeNode node) { assert ThreadUtils.isInEDT(); if (myExpandingNodes.contains(node)) { return; } myExpandingNodes.add(node); try { doInit(node, new Runnable() { @Override public void run() { node.doInit(); } }); } finally { myExpandingNodes.remove(node); } } /** * Extra initialization stuff, by default adds progress indication (conditional, myLoadingDisabled and node.isLoadingEnabled). * Sub-classes may override to provide extra initialization stuff (shall invoke nodeInitRunnable if do not delegate to super) * or to wrap nodeInitRunnable into proper access code (e.g. model read if tree node initialization might demand access to model). * * Note, if tree nodes do not initialize lazily with access to a data model, there's no need to override the method and to wrap nodeInitRunnable with * data model access control (e.g. model read) */ protected void doInit(MPSTreeNode node, Runnable nodeInitRunnable) { TextTreeNode progressNode = null; if (!myLoadingDisabled && node.isLoadingEnabled()) { progressNode = new TextTreeNode("loading..."); node.add(progressNode); ((DefaultTreeModel) getModel()).nodeStructureChanged(node); expandPath(new TreePath(progressNode.getPath())); Graphics g = getGraphics(); if (g != null && g.getClipBounds() != null) { paint(g); } } nodeInitRunnable.run(); if (!myLoadingDisabled && node.isLoadingEnabled() && node.hasChild(progressNode)) { // node initialization code (nodeInitRunnable, node.doInit()) may remove all the children node.remove(progressNode); } // initialization of a node is supposed to update its children, notify structure had likely changed ((DefaultTreeModel) getModel()).nodeStructureChanged(node); } public void addTreeNodeListener(MPSTreeNodeListener listener) { myTreeNodeListeners.add(listener); } public void removeTreeNodeListener(MPSTreeNodeListener listener) { myTreeNodeListeners.remove(listener); } public void fireBeforeTreeDisposed() { for (MPSTreeNodeListener listener : new HashSet<MPSTreeNodeListener>(myTreeNodeListeners)) { listener.beforeTreeDisposed(this); } } void fireTreeNodeUpdated(MPSTreeNode node) { for (MPSTreeNodeListener listener : new HashSet<MPSTreeNodeListener>(myTreeNodeListeners)) { listener.treeNodeUpdated(node, this); } } void fireTreeNodeAdded(MPSTreeNode node) { for (MPSTreeNodeListener listener : new HashSet<MPSTreeNodeListener>(myTreeNodeListeners)) { listener.treeNodeAdded(node, this); } } void fireTreeNodeRemoved(MPSTreeNode node) { for (MPSTreeNodeListener listener : new HashSet<MPSTreeNodeListener>(myTreeNodeListeners)) { listener.treeNodeRemoved(node, this); } } void myMouseReleased(MouseEvent e) { if (e.isPopupTrigger()) showPopup(e.getX(), e.getY()); } void myMousePressed(final MouseEvent e) { IdeFocusManager.findInstanceByComponent(this).requestFocus(this, true); if (e.getButton() == 0) { // This is a workaround for handling context menu button TreePath path = getSelectionPath(); if (path == null) return; int rowNum = getRowForPath(path); Rectangle r = getRowBounds(rowNum); showPopup(r.x, r.y); return; } if (e.isPopupTrigger()) { showPopup(e.getX(), e.getY()); return; } TreePath path = getClosestPathForLocation(e.getX(), e.getY()); if (path == null) return; MPSTreeNode nodeToClick = getOpenableNode(e); if (nodeToClick != null) { if ((e.getClickCount() == 1 && isAutoOpen())) { autoscroll(nodeToClick); } } } private void myMouseClicked(MouseEvent e) { if (e.isPopupTrigger()) { showPopup(e.getX(), e.getY()); return; } MPSTreeNode nodeToClick = getOpenableNode(e); if (nodeToClick != null && e.getClickCount() == 2) { doubleClick(nodeToClick); } } private MPSTreeNode getOpenableNode(MouseEvent e) { MPSTreeNode node = getNodeFromPath(getClosestPathForLocation(e.getX(), e.getY())); if (node == null) return null; if (!node.canBeOpened()) return null; return node; } @Nullable private MPSTreeNode getNodeFromPath(@Nullable TreePath path) { if (path == null) return null; Object lastPathComponent = path.getLastPathComponent(); if (!(lastPathComponent instanceof MPSTreeNode)) return null; return (MPSTreeNode) lastPathComponent; } /** * Gives owning tree a chance to process double-click event. * By default, delegates to {@link MPSTreeNode#doubleClick()} */ protected void doubleClick(@NotNull MPSTreeNode nodeToClick) { nodeToClick.doubleClick(); } /** * Single point to dispatch auto scrolling event. * By default, delegates to {@link MPSTreeNode#autoscroll()} ()} */ protected void autoscroll(@NotNull MPSTreeNode nodeToClick) { nodeToClick.autoscroll(); } // if we navigate to a node which is MPSTreeNode#isAutoExpandable() == true, // it's automatically expanded, unless myAutoExpandEnabled == false // XXX is there any case but select node in the runnable? Perhaps, could // get better api (e.g. selectWithoutExpansion(MPSTreeNode)?) public void runWithoutExpansion(Runnable r) { try { myAutoExpandEnabled = false; r.run(); } finally { myAutoExpandEnabled = true; } } public boolean isAutoOpen() { return myAutoOpen; } public void setAutoOpen(boolean autoOpen) { myAutoOpen = autoOpen; } @Override public String getToolTipText(MouseEvent event) { TreePath path = getPathForLocation(event.getX(), event.getY()); MPSTreeNode node = getNodeFromPath(path); if (node != null) { return node.getTooltipText(); } return null; } protected JPopupMenu createDefaultPopupMenu() { return null; } protected final JPopupMenu createPopupMenu(final MPSTreeNode node) { ActionGroup actionGroup = createPopupActionGroup(node); if (actionGroup == null) return null; return ActionManager.getInstance().createActionPopupMenu(getPopupMenuPlace(), actionGroup).getComponent(); } protected String getPopupMenuPlace() { return ActionPlaces.UNKNOWN; } protected ActionGroup createPopupActionGroup(final MPSTreeNode node) { return null; } private boolean insideTreeItemsArea(int y) { Rectangle rowBounds = getRowBounds(getRowCount() - 1); if (rowBounds == null) { return false; } double lastItemBottomLine = rowBounds.getMaxY(); return y <= lastItemBottomLine; } private boolean isWideSelectionUI() { TreeUI ui = getUI(); return ui instanceof WideSelectionTreeUI && ((WideSelectionTreeUI) ui).isWideSelection(); } private void showPopup(int x, int y) { if (!insideTreeItemsArea(y)) { return; } TreePath closestPath = getClosestPathForLocation(x, y); TreePath path; if (isWideSelectionUI()) { path = closestPath; } else { path = getPathForLocation(x, y); } // Always select the closest path, even if the tree does not have wide selection, or the node doesn't have a menu. if (closestPath != null && !isPathSelected(closestPath)) { setSelectionPath(path); } final MPSTreeNode node = getNodeFromPath(path); if (node != null) { JPopupMenu menu = createPopupMenu(node); if (menu != null) { menu.show(this, x, y); return; } } JPopupMenu defaultMenu = createDefaultPopupMenu(); if (defaultMenu == null) return; defaultMenu.show(this, x, y); } @Nullable public Comparator<Object> getChildrenComparator() { return null; } protected abstract MPSTreeNode rebuild(); public void expandAll() { MPSTreeNode node = getRootNode(); expandAll(node); } public void collapseAll() { MPSTreeNode node = getRootNode(); collapseAll(node); } public void selectFirstLeaf() { List<MPSTreeNode> path = new ArrayList<MPSTreeNode>(); MPSTreeNode current = getRootNode(); while (true) { path.add(current); if (current.getChildCount() == 0) break; current = (MPSTreeNode) current.getChildAt(0); } setSelectionPath(new TreePath(path.toArray())); } public void expandRoot() { expandPath(new TreePath(new Object[]{getRootNode()})); getRootNode().init(); } public void expandAll(MPSTreeNode node) { boolean wasLoadingDisabled = myLoadingDisabled; myLoadingDisabled = true; try { expandAllImpl(node); } finally { myLoadingDisabled = wasLoadingDisabled; } } private void expandAllImpl(MPSTreeNode node) { expandPath(new TreePath(node.getPath())); for (MPSTreeNode c : node) { expandAllImpl(c); } } public void collapseAll(MPSTreeNode node) { boolean wasAutoExpandEnabled = myAutoExpandEnabled; try { myAutoExpandEnabled = false; collapseAllImpl(node); } finally { myAutoExpandEnabled = wasAutoExpandEnabled; } } private void collapseAllImpl(MPSTreeNode node) { for (MPSTreeNode c : node) { collapseAllImpl(c); } if (node.isInitialized()) { super.collapsePath(new TreePath(node.getPath())); } } public void selectNode(TreeNode node) { List<TreeNode> nodes = new ArrayList<TreeNode>(); while (node != null) { nodes.add(0, node); node = node.getParent(); } if (nodes.size() == 0) return; TreePath path = new TreePath(nodes.toArray()); setSelectionPath(path); scrollRowToVisible(getRowForPath(path)); } // FIXME perhaps, shall be protected, rebuildAction is sort of implementation detail when there are rebuildNow() and rebuildLater() public void runRebuildAction(final Runnable rebuildAction, final boolean saveExpansion) { if (RuntimeFlags.isTestMode()) { return; } if (!ThreadUtils.isInEDT()) { throw new RuntimeException("Rebuild now can be only called from UI thread"); } myLoadingDisabled = true; try { Runnable restoreExpansion = null; if (saveExpansion) { final List<String> expansion = getExpandedPaths(); final List<String> selection = getSelectedPaths(); restoreExpansion = new Runnable() { @Override public void run() { expandPaths(expansion); selectPaths(selection); } }; } // debugger.GroupedTree assumes read action for rebuild() call ModelAccess.instance().runReadAction(rebuildAction); if (restoreExpansion != null) { runWithoutExpansion(restoreExpansion); } } finally { myLoadingDisabled = false; } } // XXX Rename to scheduleRebuild()? public void rebuildLater() { myQueue.queue(new SafeUpdate(myUpdateId, new Runnable() { @Override public void run() { // myQueue is attached to EDT if (MPSTree.this.isDisposed()) return; rebuildNow(); } }, LOG)); } public void rebuildNow() { if (!ThreadUtils.isInEDT()) { throw new RuntimeException("Rebuild now can be only called from UI thread"); } assert !isDisposed() : "Trying to reconstruct disposed tree. Try finding \"later\" in stacktrace"; runRebuildAction(new Runnable() { @Override public void run() { setAnchorSelectionPath(null); setLeadSelectionPath(null); MPSTreeNode root = rebuild(); setRootNode(root); } }, true); } public void clear() { setRootNode(new TextTreeNode("Empty")); } private void setRootNode(@Nullable MPSTreeNode root) { final Object oldRoot = getModel().getRoot(); if (oldRoot instanceof MPSTreeNode) { ((MPSTreeNode) oldRoot).removeThisAndChildren(); ((MPSTreeNode) oldRoot).setTree(null); } if (root != null) { root.setTree(this); root.addThisAndChildren(); } DefaultTreeModel model = new DefaultTreeModel(root); setModel(model); } private String pathToString(TreePath path) { StringBuilder result = new StringBuilder(); for (int i = 1; i < path.getPathCount(); i++) { MPSTreeNode node = (MPSTreeNode) path.getPathComponent(i); result.append(TREE_PATH_SEPARATOR); result.append(node.getNodeIdentifier().replaceAll(TREE_PATH_SEPARATOR, "-")); } if (result.length() == 0) return TREE_PATH_SEPARATOR; return result.toString(); } public TreeNode findNodeWith(Object userObject) { MPSTreeNode root = getRootNode(); return findNodeWith(root, userObject); } public MPSTreeNode getRootNode() { return (MPSTreeNode) getModel().getRoot(); } public MPSTreeNode getCurrentNode() { return getNodeFromPath(getLeadSelectionPath()); } public void setCurrentNode(@NotNull MPSTreeNode node) { TreePath path = new TreePath(node.getPath()); setSelectionPath(path); this.scrollPathToVisible(path); } private MPSTreeNode findNodeWith(MPSTreeNode root, Object userObject) { if (root.getUserObject() == userObject) return root; if (!(root.isInitialized() || root.hasInfiniteSubtree())) root.init(); for (MPSTreeNode child : root) { MPSTreeNode result = findNodeWith(child, userObject); if (result != null) return result; } return null; } private TreePath listToPath(List<String> pathComponents) { return getTreePath(pathComponents, false); } private TreePath stringToPath(String pathString) { List<String> components = Arrays.asList(pathString.split(TREE_PATH_SEPARATOR)); return getTreePath(components, true); } @Nullable private TreePath getTreePath(List<String> components, boolean escapePathSep) { List<Object> path = new ArrayList<Object>(); MPSTreeNode current = getRootNode(); current.init(); path.add(current); for (Iterator<String> it = components.iterator(); it.hasNext(); ) { String component = it.next(); assert current.isInitialized(); if (component == null || component.length() == 0) continue; boolean found = false; for (int i = 0; i < current.getChildCount(); i++) { MPSTreeNode node = (MPSTreeNode) current.getChildAt(i); String treeNodeId = escapePathSep ? node.getNodeIdentifier().replaceAll(TREE_PATH_SEPARATOR, "-") : node.getNodeIdentifier(); if (treeNodeId.equals(component)) { current = node; path.add(current); if (!current.isInitialized() && it.hasNext()) { current.init(); } found = true; break; } } if (!found) { return null; } } return new TreePath(path.toArray()); } protected void expandPathsRaw(List<List<String>> paths) { for (List<String> path : paths) { TreePath treePath = listToPath(path); if (treePath != null) { ensurePathInitialized(treePath); expandPath(treePath); } } } protected void expandPaths(List<String> paths) { for (String path : paths) { TreePath treePath = stringToPath(path); if (treePath != null) { ensurePathInitialized(treePath); expandPath(treePath); } } } private void ensurePathInitialized(TreePath path) { for (Object item : path.getPath()) { MPSTreeNode node = (MPSTreeNode) item; node.init(); } } protected void selectPaths(List<String> paths) { List<TreePath> treePaths = new ArrayList<TreePath>(); for (String path : paths) { treePaths.add(stringToPath(path)); } setSelectionPaths(treePaths.toArray(new TreePath[treePaths.size()])); } protected void selectPathsRaw(List<List<String>> paths) { List<TreePath> treePaths = new ArrayList<TreePath>(); for (List<String> path : paths) { treePaths.add(listToPath(path)); } setSelectionPaths(treePaths.toArray(new TreePath[treePaths.size()])); } // TODO: refactor TreeState to include these instead of the old format public List<List<String>> getExpandedPathsRaw() { List<List<String>> result = new ArrayList<List<String>>(); Enumeration<TreePath> expanded = getExpandedDescendants(new TreePath(new Object[]{getModel().getRoot()})); if (expanded == null) return result; while (expanded.hasMoreElements()) { List<String> path = new ArrayList<String>(); TreePath expandedPath = expanded.nextElement(); if (expandedPath.getLastPathComponent() == getModel().getRoot()) { continue; } for (int i = 1; i < expandedPath.getPathCount(); i++) { MPSTreeNode node = (MPSTreeNode) expandedPath.getPathComponent(i); path.add(node.getNodeIdentifier()); } result.add(path); } return result; } private List<String> getExpandedPaths() { List<String> result = new ArrayList<String>(); Enumeration<TreePath> expanded = getExpandedDescendants(new TreePath(new Object[]{getModel().getRoot()})); if (expanded == null) return result; while (expanded.hasMoreElements()) { TreePath path = expanded.nextElement(); String pathString = pathToString(path); if (result.contains(pathString)) LOG.warn("two expanded paths have the same string representation"); result.add(pathString); } return result; } // TODO: refactor TreeState to include these instead of the old format public List<List<String>> getSelectedPathsRaw() { List<List<String>> result = new ArrayList<List<String>>(); if (getSelectionPaths() == null) return result; for (TreePath selectedPath: getSelectionPaths()) { List<String> path = new ArrayList<String>(); for (int i = 1; i < selectedPath.getPathCount(); i++) { MPSTreeNode node = (MPSTreeNode) selectedPath.getPathComponent(i); path.add(node.getNodeIdentifier()); } result.add(path); } return result; } private List<String> getSelectedPaths() { List<String> result = new ArrayList<String>(); if (getSelectionPaths() == null) return result; for (TreePath selectionPart : getSelectionPaths()) { String pathString = pathToString(selectionPart); if (result.contains(pathString)) LOG.warn("two selected paths have the same string representation"); result.add(pathString); } return result; } public TreeState saveState() { TreeState result = new TreeState(); result.myExpansion.addAll(getExpandedPaths()); result.mySelection.addAll(getSelectedPaths()); return result; } public void loadState(TreeState state) { selectPaths(state.mySelection); expandPaths(state.myExpansion); } // TODO: refactor TreeState to include these instead of the old format public void loadState(List<List<String>> expandedPaths, List<List<String>> selectedPaths) { expandPathsRaw(expandedPaths); selectPathsRaw(selectedPaths); } @Override public int getToggleClickCount() { MPSTreeNode node = getNodeFromPath(getSelectionPath()); if (node == null) return -1; return node.getToggleClickCount(); } public boolean isDisposed() { return myDisposed; } @Override public void dispose() { assert !myDisposed; fireBeforeTreeDisposed(); myDisposed = true; setRootNode(null); myTreeNodeListeners.clear(); } public static class TreeState { private List<String> myExpansion = new ArrayList<String>(); private List<String> mySelection = new ArrayList<String>(); public List<String> getExpansion() { return myExpansion; } public void setExpansion(List<String> expansion) { myExpansion = expansion; } public List<String> getSelection() { return mySelection; } public void setSelection(List<String> selection) { mySelection = selection; } } private class MyTreeWillExpandListener implements TreeWillExpandListener { @Override public void treeWillExpand(TreeExpansionEvent event) throws ExpandVetoException { MPSTreeNode treeNode = getNodeFromPath(event.getPath()); if (treeNode != null) treeNode.init(); } @Override public void treeWillCollapse(TreeExpansionEvent event) throws ExpandVetoException { } } private class MyTreeExpansionListener implements TreeExpansionListener { @Override public void treeExpanded(TreeExpansionEvent event) { if (!myAutoExpandEnabled) return; TreePath eventPath = event.getPath(); MPSTreeNode node = getNodeFromPath(eventPath); if (node != null && node.getChildCount() == 1) { List<MPSTreeNode> path = new ArrayList<MPSTreeNode>(); for (Object item : eventPath.getPath()) { path.add((MPSTreeNode) item); } MPSTreeNode onlyChild = (MPSTreeNode) node.getChildAt(0); if (onlyChild.isAutoExpandable()) { path.add(onlyChild); expandPath(new TreePath(path.toArray())); } } } @Override public void treeCollapsed(TreeExpansionEvent event) { } } private class MyMouseAdapter extends MouseAdapter { @Override public void mousePressed(MouseEvent e) { myMousePressed(e); } @Override public void mouseReleased(MouseEvent e) { myMouseReleased(e); } @Override public void mouseClicked(MouseEvent e) { myMouseClicked(e); } @Override public void mouseEntered(MouseEvent e) { myTooltipManagerRecentInitialDelay = ToolTipManager.sharedInstance().getInitialDelay(); ToolTipManager.sharedInstance().setInitialDelay(10); } @Override public void mouseExited(MouseEvent e) { ToolTipManager.sharedInstance().setInitialDelay(myTooltipManagerRecentInitialDelay); } } private class MyOpenNodeAction extends AbstractAction { @Override public void actionPerformed(ActionEvent e) { MPSTreeNode selNode = getNodeFromPath(getSelectionPath()); if (selNode == null) return; doubleClick(selNode); } } private class MyRefreshAction extends AbstractAction { @Override public void actionPerformed(ActionEvent e) { rebuildNow(); } } // XXX this class is worth re-use private static final class SafeUpdate extends Update { private final Runnable myDelegate; @Nullable private Logger myLogger; private Exception myException; public SafeUpdate(@NotNull Object updateId, @NotNull Runnable updateCode, @Nullable Logger logger) { super(updateId); myDelegate = updateCode; myLogger = logger; } @Override public void run() { try { myDelegate.run(); } catch (Exception ex) { if (myLogger != null) { myLogger.error("Update failed", ex); } myException = ex; } } @Nullable public Exception getException() { return myException; } } }