/*
* Copyright 2010 the original author or authors.
*
* 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 org.gradle.gradleplugin.userinterface.swing.generic;
import org.gradle.foundation.ProjectView;
import org.gradle.foundation.TaskView;
import org.gradle.foundation.visitors.TaskTreePopulationVisitor;
import org.gradle.gradleplugin.foundation.GradlePluginLord;
import org.gradle.gradleplugin.foundation.filters.ProjectAndTaskFilter;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JComponent;
import javax.swing.JPanel;
import javax.swing.JTree;
import javax.swing.KeyStroke;
import javax.swing.SwingUtilities;
import javax.swing.ToolTipManager;
import javax.swing.tree.DefaultMutableTreeNode;
import javax.swing.tree.DefaultTreeCellRenderer;
import javax.swing.tree.DefaultTreeModel;
import javax.swing.tree.TreeCellRenderer;
import javax.swing.tree.TreeNode;
import javax.swing.tree.TreePath;
import java.awt.Color;
import java.awt.Component;
import java.awt.Font;
import java.awt.Point;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.KeyEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Enumeration;
/**
* This displays a tree of projects, subprojects, and tasks. You implement the Interaction to detemine how to handle
* right clicks and double clicking tasks. To use this, call populate and pass it a filter (allows you to change exactly
* what is displayed). There are several functions to obtaining the selected items, plus you can get the tree directly
* for any advanced functionality.
*
* @author mhunsicker
*/
public class TaskTreeComponent {
private GradlePluginLord gradlePluginLord;
private Interaction interaction;
private JTree tree;
private DefaultTreeModel model;
private TaskTreeBaseNode rootNode;
private boolean isPopulated;
private TaskTreeComponent.Renderer renderer;
public interface Interaction {
void rightClick(JTree tree, int x, int y);
/**
* Notification that a project was invoked (double-clicked). Do whatever you like, such as execute its default
* task.
*
* @param project the project that was invoked.
*/
void projectInvoked(ProjectView project);
/**
* Notification that a task was invoked (double-clicked). Do whatever you like, such as execute it.
*
* @param task the task that was invoked.
* @param isCtrlKeyDown true if the CTRL key was pressed at the time
*/
void taskInvoked(TaskView task, boolean isCtrlKeyDown);
}
public TaskTreeComponent(GradlePluginLord gradlePluginLord, Interaction interaction) {
this.gradlePluginLord = gradlePluginLord;
this.interaction = interaction;
createTreePanel();
}
private void createTreePanel() {
rootNode = new TaskTreeBaseNode();
model = new DefaultTreeModel(rootNode);
tree = new JTree(model);
tree.setRootVisible(false);
tree.setShowsRootHandles(true);
renderer = new Renderer();
tree.setCellRenderer(renderer);
ToolTipManager.sharedInstance().registerComponent(tree);
tree.setToggleClickCount(
99); //prevents double clicks from expanding/collapsing the tree. We want to treat them as double-clicks
tree.addMouseListener(new MyMouseListener());
//make hitting Enter and CTRL Enter on a node equal executing it (the first node at least)
tree.registerKeyboardAction(new ActionListener() {
public void actionPerformed(ActionEvent e) {
executeFirstSelectedNode(false);
}
}, KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), JComponent.WHEN_IN_FOCUSED_WINDOW);
tree.registerKeyboardAction(new ActionListener() {
public void actionPerformed(ActionEvent e) {
executeFirstSelectedNode(true);
}
}, KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, KeyEvent.CTRL_MASK), JComponent.WHEN_IN_FOCUSED_WINDOW);
}
public JTree getTree() {
return tree;
}
public void setTreeCellRenderer(TreeCellRenderer renderer) {
tree.setCellRenderer(renderer);
}
/**
* This renders our projects and tasks. This removes the icon and optionally shows the description in a different
* color. Since there's quite a bit of code for handling rendering tree cells, I'm just going to mooch off of the
* DefaultTreeCellRenderer. I'll just modify it's behavior a little (I probably don't need that or the description
* since it's not going to draw a selection or highlight).
*/
private class Renderer implements TreeCellRenderer {
private JPanel panel;
private DefaultTreeCellRenderer nameRenderer;
private DefaultTreeCellRenderer descriptionRenderer;
private Color descriptionColor;
private boolean showDescription = true;
private Component seperator;
private Font normalFont;
private Font boldFont;
private Renderer() {
setupRendererUI();
setShowDescription(true);
descriptionColor = Color.blue;
}
private void setupRendererUI() {
panel = new JPanel();
panel.setLayout(new BoxLayout(panel, BoxLayout.X_AXIS));
nameRenderer = new DefaultTreeCellRenderer();
descriptionRenderer = new DefaultTreeCellRenderer();
panel.add(nameRenderer);
seperator = Box.createHorizontalStrut(10);
panel.add(seperator);
panel.add(descriptionRenderer);
panel.setOpaque(false);
setupFonts();
}
/**
* Setup the fonts. On some platforms, bold is the typical version. We explicitly don't want that. So we'll make
* the fonts plain and use the bold for our own purposes (indicating default tasks).
*/
private void setupFonts() {
normalFont = nameRenderer.getFont().deriveFont(Font.PLAIN);
boldFont = normalFont.deriveFont(Font.BOLD);
nameRenderer.setFont(normalFont);
descriptionRenderer.setFont(normalFont);
}
public boolean showDescription() {
return showDescription;
}
//the easiest thing to do is just hide the description and its separator
public void setShowDescription(boolean showDescription) {
this.showDescription = showDescription;
seperator.setVisible(showDescription);
descriptionRenderer.setVisible(showDescription);
seperator.invalidate();
nameRenderer.invalidate();
descriptionRenderer.invalidate();
panel.invalidate();
//have to tell the tree each node changed. This is so it will recalculate its size. Without this, if the description is
//initially disabled, the tree is populated and expanded, then description is enabled, nothing shows up because the tree
//caches the node's size for some dumb reason.
Enumeration enumeration = rootNode.breadthFirstEnumeration();
while (enumeration.hasMoreElements()) {
TaskTreeBaseNode treeNode = (TaskTreeBaseNode) enumeration.nextElement();
model.nodeChanged(treeNode);
}
tree.repaint();
}
public Component getTreeCellRendererComponent(JTree tree, Object value, boolean isSelected, boolean expanded,
boolean leaf, int row, boolean hasFocus) {
TaskTreeBaseNode node = (TaskTreeBaseNode) value;
String description = node.getDescription();
//we've already added these components to our panel. We know they're just labels. Calling getTreeCell... just sets their text and colors correctly.
this.nameRenderer.getTreeCellRendererComponent(tree, node.toString(), isSelected, expanded, leaf, row,
hasFocus);
this.descriptionRenderer.getTreeCellRendererComponent(tree, description, isSelected, expanded, leaf, row,
false);
//set the tooltip. This must be on the component we return not our sub renderers
panel.setToolTipText(description);
//just remove the icon entirely
nameRenderer.setIcon(null);
this.descriptionRenderer.setIcon(null);
if (node.isBold()) {
nameRenderer.setFont(boldFont);
} else {
nameRenderer.setFont(normalFont);
}
//set the description color. If its selected, make it the name renderer's color
//so we know the colors won't conflict (they do on Windows XP).
if (!isSelected) {
this.descriptionRenderer.setForeground(descriptionColor);
} else {
this.descriptionRenderer.setForeground(nameRenderer.getForeground());
}
nameRenderer.invalidate();
descriptionRenderer.invalidate();
seperator.invalidate();
panel.invalidate();
panel.validate();
return panel;
}
}
private class MyMouseListener extends MouseAdapter {
public void mousePressed(MouseEvent e) {
if (e.getButton() == MouseEvent.BUTTON3) { //This is here just to select a node that we right click on.
//The tree really needs to handle this for us.
Point point = e.getPoint();
int row = tree.getRowForLocation(point.x, point.y);
if (row != -1) {
if (tree.isRowSelected(row)) {
return;
} //if its already selected, just leave it alone. This prevents us from changing selecting when a user right-clicks on one of many selected items.
//we need to determine if we move the selection, or just add it to the existing selection.
if (isAddToSelectionKey(e)) {
tree.addSelectionRow(row);
} else {
tree.setSelectionRow(row);
}
}
}
}
private boolean isAddToSelectionKey(
MouseEvent e) { //this is actually OS-specific, but for now, I'll just use CTRL.
return (e.getModifiers() & MouseEvent.CTRL_MASK) != 0;
}
public void mouseClicked(MouseEvent e) {
if (e.getClickCount() == 1) {
if (e.getButton() == MouseEvent.BUTTON3) {
Point point = e.getPoint();
interaction.rightClick(tree, point.x, point.y);
}
} else if (e.getClickCount() == 2) {
TaskTreeBaseNode node = getNodeAtPoint(e.getPoint());
if (node != null) {
boolean isCtrlKeyDown = (e.getModifiers() & MouseEvent.CTRL_MASK) != 0;
node.executeTask(isCtrlKeyDown);
}
}
}
}
/**
* This populates (and repopulates) the tree. This is surprisingly tedious in an effort to make the tree collapse as
* little as possible.
*/
public void populate(ProjectAndTaskFilter filter) {
TaskTreePopulationVisitor.visitProjectAndTasks(gradlePluginLord.getProjects(), new PopulateTreeVisitor(),
filter, rootNode);
model.reload();
SwingUtilities.invokeLater(new Runnable() {
public void run() { //this expands the 'root' project
tree.expandRow(0);
}
});
isPopulated = true;
}
public boolean isPopulated() {
return isPopulated;
}
/**
* This visitor populates the tree as we walk projects and tasks. This jumpts through quite a bit of hoops in an
* effort to keep the tree from collapsing. It still does, but not completely. In order to keep it from collapsing,
* you must track some additional information that frankly the tree could and should do for you.
*/
private class PopulateTreeVisitor implements TaskTreePopulationVisitor.Visitor<TaskTreeBaseNode, TaskTreeNode> {
/**
* This is called for each project.
*
* @param project the project
* @param parentProjectObject whatever you handed back from a prior call to visitProject if this is a sub
* project. Otherwise, it'll be whatever was passed into the visitPojectsAndTasks function.
* @return an object that will be handed back to you for each of this project's tasks.
*/
public TaskTreeBaseNode visitProject(ProjectView project, int indexOfProject,
TaskTreeBaseNode parentProjectObject) {
ProjectTreeNode projectTreeNode = findProjectChild(parentProjectObject, project.getName());
if (projectTreeNode == null) {
projectTreeNode = new ProjectTreeNode(project);
}
int actualIndex = parentProjectObject.getIndex(projectTreeNode);
if (actualIndex != indexOfProject) //this will be -1 for a new node
{
if (actualIndex != -1) //only try to remove it if its already there. Swing doesn't like this otherwise.
{
model.removeNodeFromParent(projectTreeNode);
}
insertChildNode(parentProjectObject, projectTreeNode, indexOfProject);
}
return projectTreeNode;
}
private ProjectTreeNode findProjectChild(TaskTreeBaseNode parentNode, String projectName) {
for (int index = 0; index < parentNode.getChildCount(); index++) {
TreeNode child = parentNode.getChildAt(index);
if (child instanceof ProjectTreeNode) {
if (((ProjectTreeNode) child).getProject().getName().equals(projectName)) {
return (ProjectTreeNode) child;
}
}
}
return null;
}
/**
* This is called for each task.
*
* @param task the task
* @param indexOfTask index
* @param tasksProject the project for this task
*/
public TaskTreeNode visitTask(TaskView task, int indexOfTask, ProjectView tasksProject,
TaskTreeBaseNode parentTreeNode) {
TaskTreeNode taskTreeNode = findTaskChild((ProjectTreeNode) parentTreeNode, task.getName());
if (taskTreeNode == null) {
taskTreeNode = new TaskTreeNode(task);
}
int actualIndex = parentTreeNode.getIndex(taskTreeNode);
if (actualIndex != indexOfTask) //this will be -1 for a new node
{
if (actualIndex != -1) //only try to remove it if its already there. Swing doesn't like this otherwise.
{
model.removeNodeFromParent(taskTreeNode);
}
insertChildNode(parentTreeNode, taskTreeNode, indexOfTask);
}
return taskTreeNode;
}
//This only exists so we can call insert or add appropriately.
//The stupid tree isn't smart enough to do this for you.
private void insertChildNode(DefaultMutableTreeNode parent, DefaultMutableTreeNode child, int index) {
if (parent.getChildCount() < index) {
parent.add(child);
model.nodesWereInserted(parent, new int[]{parent.getChildCount() - 1});
} else {
parent.insert(child, index);
model.nodesWereInserted(parent, new int[]{index});
}
}
private TaskTreeNode findTaskChild(ProjectTreeNode parentNode, String taskName) {
for (int index = 0; index < parentNode.getChildCount(); index++) {
TreeNode child = parentNode.getChildAt(index);
if (child instanceof TaskTreeNode) {
if (((TaskTreeNode) child).getTask().getName().equals(taskName)) {
return (TaskTreeNode) child;
}
}
}
return null;
}
/**
* This is called when a project has been visited completely and is just a notification giving you an
* opportunity to do whatever you like.
*
* Here, we're going to remove any nodes that aren't in either of the lists. This is when a task or project is
* hidden or when things simply change.
*
* @param parentProjectObject the object that represents the parent of the project and task objects below
* @param projectObjects a list of whatever you returned from visitProject
* @param taskObjects a list of whatever you returned from visitTask
*/
public void completedVisitingProject(TaskTreeBaseNode parentProjectObject,
List<TaskTreeBaseNode> projectObjects, List<TaskTreeNode> taskObjects) {
int index = 0;
while (index < parentProjectObject.getChildCount()) {
TaskTreeBaseNode child = (TaskTreeBaseNode) parentProjectObject.getChildAt(index);
if (!projectObjects.contains(child) && !taskObjects.contains(child)) {
model.removeNodeFromParent(child);
} else {
index++;
}
}
}
}
private void expandNode(TreeNode node) {
tree.expandPath(new TreePath(node));
}
/**
* This is a basic tree node. All nodes in this tree must extend this. This is so we don't have to deal with all the
* differing types of things that may be in this tree.
*/
public class TaskTreeBaseNode extends DefaultMutableTreeNode {
public void executeTask(boolean isCtrlKeyDown) {
} //do nothing by default.
public String toString() {
return "hidden-root";
} //by default, its the root.
public String getDescription() {
return null;
}
public boolean isBold() {
return false;
}
}
/**
* This represents a project.
*/
public class ProjectTreeNode extends TaskTreeBaseNode {
private ProjectView project;
private ProjectTreeNode(ProjectView project) {
this.project = project;
}
public String toString() {
return project.getName();
}
@Override
public void executeTask(boolean isCtrlKeyDown) {
interaction.projectInvoked(project);
}
@Override
public String getDescription() {
return project.getDescription();
}
public ProjectView getProject() {
return project;
}
}
/**
* This represents a single task.
*/
public class TaskTreeNode extends TaskTreeBaseNode {
private TaskView task;
private TaskTreeNode(TaskView task) {
this.task = task;
}
public String toString() {
return task.getName();
}
@Override
public void executeTask(boolean isCtrlKeyDown) {
interaction.taskInvoked(task, isCtrlKeyDown);
}
public TaskView getTask() {
return task;
}
@Override
public String getDescription() {
return task.getDescription();
}
@Override
public boolean isBold() {
return task.isDefault();
}
}
/**
* Returns the node at the specified point.
*/
public TaskTreeBaseNode getNodeAtPoint(Point point) {
int row = tree.getRowForLocation(point.x, point.y);
if (row == -1) {
return null;
}
TreePath path = tree.getPathForLocation(point.x, point.y);
if (path == null) {
return null;
}
return (TaskTreeBaseNode) path.getLastPathComponent();
}
/**
* @return the first selected node or null if nothing is selected.
*/
public TaskTreeBaseNode getFirstSelectedNode() {
TreePath path = tree.getSelectionPath();
if (path == null) {
return null;
}
return (TaskTreeBaseNode) path.getLastPathComponent();
}
/**
* @return a list of TaskTabTreeNode based on what is selected in the tree or an empty list if nothing is selected.
*/
public List<TaskTreeBaseNode> getSelectedNodes() {
TreePath[] treePaths = tree.getSelectionPaths();
if (treePaths == null) {
return Collections.emptyList();
}
List<TaskTreeBaseNode> nodes = new ArrayList<TaskTreeBaseNode>();
for (int index = 0; index < treePaths.length; index++) {
TreePath treePath = treePaths[index];
nodes.add((TaskTreeBaseNode) treePath.getLastPathComponent());
}
return nodes;
}
/**
* Determines if we have any projects selected. This ignores selected tasks.
*
* @return true if we have projects selected, false otherwise.
*/
public boolean hasProjectsSelected() {
TreePath[] treePaths = tree.getSelectionPaths();
if (treePaths == null) {
return false;
}
for (int index = 0; index < treePaths.length; index++) {
TreePath treePath = treePaths[index];
Object o = treePath.getLastPathComponent();
if (o instanceof ProjectTreeNode) {
return true;
}
}
return false;
}
/**
* Determines if we have any tasks selected. This ignores selected projects.
*
* @return true if we have tasks selected, false otherwise.
*/
public boolean hasTasksSelected() {
TreePath[] treePaths = tree.getSelectionPaths();
if (treePaths == null) {
return false;
}
for (int index = 0; index < treePaths.length; index++) {
TreePath treePath = treePaths[index];
Object o = treePath.getLastPathComponent();
if (o instanceof TaskTreeNode) {
return true;
}
}
return false;
}
/**
* This returns a list of selected tasks. This ignores selected projects.
*
* @return the selected tasks. Will return an empty list if no tasks are selected.
*/
public List<TaskView> getSelectedTasks() {
TreePath[] treePaths = tree.getSelectionPaths();
if (treePaths == null) {
return Collections.emptyList();
}
List<TaskView> tasks = new ArrayList<TaskView>();
for (int index = 0; index < treePaths.length; index++) {
TreePath treePath = treePaths[index];
Object o = treePath.getLastPathComponent();
if (o instanceof TaskTreeNode) {
tasks.add(((TaskTreeNode) o).task);
}
}
return tasks;
}
/**
* Object to hold onto mutliple selections, but not just multiples of the same type of node. This separates the
* selected nodes by type. You can have multiple projects and tasks selected.
*/
public class MultipleSelection {
public List<ProjectView> projects = new ArrayList<ProjectView>();
public List<TaskView> tasks = new ArrayList<TaskView>();
}
/**
* This returns the current selection broken up into projects and tasks.
*
* @return the selected projects and tasks. This never returns null and the contained lists are never null.
*/
public MultipleSelection getSelectedProjectsAndTasks() {
MultipleSelection multipleSelection = new MultipleSelection();
TreePath[] treePaths = tree.getSelectionPaths();
if (treePaths == null) {
return multipleSelection;
}
for (int index = 0; index < treePaths.length; index++) {
TreePath treePath = treePaths[index];
Object o = treePath.getLastPathComponent();
if (o instanceof TaskTreeNode) {
multipleSelection.tasks.add(((TaskTreeNode) o).getTask());
} else if (o instanceof ProjectTreeNode) {
multipleSelection.projects.add(((ProjectTreeNode) o).getProject());
}
}
return multipleSelection;
}
public boolean showDescription() {
return renderer.showDescription();
}
public void setShowDescription(boolean showDescription) {
this.renderer.setShowDescription(showDescription);
}
private void executeFirstSelectedNode(boolean isCtrlKeyDown) {
TaskTreeComponent.TaskTreeBaseNode node = getFirstSelectedNode();
if (node != null) {
node.executeTask(isCtrlKeyDown);
}
}
}