/**
TrakEM2 plugin for ImageJ(C).
Copyright (C) 2005-2009 Albert Cardona and Rodney Douglas.
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation (http://www.gnu.org/licenses/gpl.txt )
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
You may contact Albert Cardona at acardona at ini.phys.ethz.ch
Institute of Neuroinformatics, University of Zurich / ETH, Switzerland.
**/
package ini.trakem2.tree;
import ini.trakem2.Project;
import ini.trakem2.utils.Dispatcher;
import ini.trakem2.utils.IJError;
import ini.trakem2.utils.Utils;
import java.awt.Color;
import java.awt.Component;
import java.awt.Insets;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.dnd.DnDConstants;
import java.awt.event.KeyEvent;
import java.awt.event.KeyListener;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Hashtable;
import java.util.Iterator;
import java.util.Map;
import javax.swing.JLabel;
import javax.swing.JScrollPane;
import javax.swing.JTree;
import javax.swing.KeyStroke;
import javax.swing.SwingUtilities;
import javax.swing.event.TreeExpansionEvent;
import javax.swing.event.TreeExpansionListener;
import javax.swing.tree.DefaultMutableTreeNode;
import javax.swing.tree.DefaultTreeCellRenderer;
import javax.swing.tree.DefaultTreeModel;
import javax.swing.tree.TreeModel;
import javax.swing.tree.TreePath;
import javax.swing.tree.TreeSelectionModel;
/** A JTree which has a built-in drag and drop feature.
* <p>
* Adapted from <a href="http://forum.java.sun.com/thread.jspa?threadID=296255&start=0&tstart=0">freely available code by DeuDeu</a>.
* </p>
*/
public abstract class DNDTree extends JTree implements TreeExpansionListener, KeyListener {
Insets autoscrollInsets = new Insets(20, 20, 20, 20); // insets
DefaultTreeTransferHandler dtth = null;
protected final Dispatcher dispatcher = new Dispatcher();
final protected Project project;
protected final Color background;
public DNDTree(final Project project, final DefaultMutableTreeNode root, final Color background) {
this.project = project;
this.background = background;
setAutoscrolls(true);
setModel(new DefaultTreeModel(root));
setRootVisible(true);
setShowsRootHandles(false);//to show the root icon
getSelectionModel().setSelectionMode(TreeSelectionModel.SINGLE_TREE_SELECTION); //set single selection for the Tree
setEditable(true);
//DNDTree.expandAllNodes(this, root);
// so weird this instance below does not need to be kept anywhere: where is Java storing it?
dtth = new DefaultTreeTransferHandler(project, this, DnDConstants.ACTION_COPY_OR_MOVE);
//
this.setScrollsOnExpand(true);
KeyListener[] kls = getKeyListeners();
if (null != kls) for (KeyListener kl : kls) { Utils.log2("removing kl: " + kl); removeKeyListener(kl); }
//resetKeyboardActions(); // removing the KeyListeners is not enough!
//setActionMap(new ActionMap()); // an empty one -- none of these two lines has any effect towards stopping the key events.
this.addKeyListener(this);
if (null != background) {
final DefaultTreeCellRenderer renderer = createNodeRenderer();
renderer.setBackground(background);
renderer.setBackgroundNonSelectionColor(background);
// I hate swing, I really do. And java has no closures, no macros, and reflection is nearly as verbose as the code below!
SwingUtilities.invokeLater(new Runnable() { public void run() {
DNDTree.this.setCellRenderer(renderer);
}});
SwingUtilities.invokeLater(new Runnable() { public void run() {
DNDTree.this.setBackground(background);
}});
}
}
/** Removing all KeyListener and ActionMap is not enough:
* one must override this method to stop the JTree from reacting to keys. */
@Override
protected void processKeyEvent(KeyEvent ke) {
if (ke.isConsumed()) return;
if (KeyEvent.KEY_PRESSED == ke.getID()) {
keyPressed(ke);
}
}
/** Prevent processing. */ // Never occurred so far
@Override
protected boolean processKeyBinding(KeyStroke ks,
KeyEvent e,
int condition,
boolean pressed) {
Utils.log2("intercepted binding: " + e.getKeyChar() + " " + ks.getKeyChar());
return false;
}
/** Subclasses should override this method to return a subclass of DNDTree.NodeRenderer */
protected NodeRenderer createNodeRenderer() {
return new NodeRenderer();
}
protected class NodeRenderer extends DefaultTreeCellRenderer {
private static final long serialVersionUID = 1L;
@Override
public Component getTreeCellRendererComponent(final JTree tree, final Object value, final boolean isSelected, final boolean isExpanded, final boolean isLeaf, final int row, final boolean hasTheFocus) {
final JLabel label = (JLabel) super.getTreeCellRendererComponent(tree, value, isSelected, isExpanded, isLeaf, row, hasTheFocus);
label.setText(label.getText().replace('_', ' ')); // just for display
return label;
}
/** Override to show tooltip text as well. */
@Override
public void setText(final String text) {
super.setText(text);
setToolTipText(text); // TODO doesn't work ??
}
}
public void autoscroll(Point cursorLocation) {
Insets insets = getAutoscrollInsets();
Rectangle outer = getVisibleRect();
Rectangle inner = new Rectangle(outer.x+insets.left, outer.y+insets.top, outer.width-(insets.left+insets.right), outer.height-(insets.top+insets.bottom));
if (!inner.contains(cursorLocation)) {
Rectangle scrollRect = new Rectangle(cursorLocation.x-insets.left, cursorLocation.y-insets.top, insets.left+insets.right, insets.top+insets.bottom);
scrollRectToVisible(scrollRect);
}
}
public Insets getAutoscrollInsets() {
return autoscrollInsets;
}
/*
public static DefaultMutableTreeNode makeDeepCopy(DefaultMutableTreeNode node) {
DefaultMutableTreeNode copy = new DefaultMutableTreeNode(node.getUserObject());
for (Enumeration e = node.children(); e.hasMoreElements();) {
copy.add(makeDeepCopy((DefaultMutableTreeNode)e.nextElement()));
}
return copy;
}
/** Expand all nodes recursively starting at the given node. It checks whether the node is a non-leaf first. Adapted quite literally from: http://www.koders.com/java/fid4BF9016D39E1A9EEDBB7875D3D2E1FE4DA9F726D.aspx a GPL TreeTool.java by Dirk Moebius (or so it says). */
static public void expandAllNodes(final JTree tree, final TreePath path) {
final DefaultMutableTreeNode node = (DefaultMutableTreeNode)path.getLastPathComponent();
final TreeModel tree_model = tree.getModel();
if (tree_model.isLeaf(node)) return;
tree.expandPath(path);
final int n_children = tree_model.getChildCount(node);
for (int i=0; i<n_children; i++) {
expandAllNodes(tree, path.pathByAddingChild(tree_model.getChild(node, i)));
}
}
static public boolean expandNode(final DNDTree tree, final DefaultMutableTreeNode node) {
final TreeModel tree_model = tree.getModel();
if (tree_model.isLeaf(node)) return false;
tree.expandPath(new TreePath(node.getPath()));
tree.updateUILater();
return true;
}
/** Convenient method.*/
static public void expandAllNodes(DNDTree tree, DefaultMutableTreeNode root_node) {
expandAllNodes(tree, new TreePath(root_node.getPath()));
tree.updateUILater();
}
static public DefaultMutableTreeNode makeNode(Thing thing) {
return makeNode(thing, false);
}
/** Returns a DefaultMutableTreeNode with all its children. If this is called on a root node, it will fill in the whole tree. The Attribute nodes are only added it their value is non-null, and at the top of the list. */ //This method is designed as functional programming.
static public DefaultMutableTreeNode makeNode(Thing thing, boolean childless_nested) {
//make a new node
DefaultMutableTreeNode node = new DefaultMutableTreeNode(thing);
// add attributes and children only if nested are allowed (for ProjectThing)
if (childless_nested) {
// check if the given thing has a parent (or parent of parent, etc.) of the same type as itself (in which case the node will be returned as is, and thus added childless and attributeless)
Thing parent = thing.getParent();
String type = thing.getType();
while (null != parent) {
if (type.equals(parent.getType())) {
// finish!
return node;
}
parent = parent.getParent();
}
}
//fill in with children
ArrayList<? extends Thing> al_children = thing.getChildren();
if (null == al_children) return node; // end
for (final Thing child : al_children) {
node.add(makeNode(child, childless_nested)); // recursive call
}
return node;
}
/** Find the node in the tree that contains the Thing of the given id.*/
/* // EQUALS Project.find(long id)
static public DefaultMutableTreeNode findNode(final long thing_id, final JTree tree) {
// find which node contains the thing_ob
DefaultMutableTreeNode node = (DefaultMutableTreeNode)tree.getModel().getRoot();
if (((DBObject)node.getUserObject()).getId() == thing_id) return node; // the root itself
Enumeration e = node.depthFirstEnumeration();
while (e.hasMoreElements()) {
node = (DefaultMutableTreeNode)e.nextElement();
if (((DBObject)node.getUserObject()).getId() == thing_id) {
//gotcha
return node;
}
}
return null;
}
*/
/** Find the node in the tree that contains the given Thing.*/
static public DefaultMutableTreeNode findNode(final Object thing_ob, final JTree tree) {
if (null != thing_ob) {
// find which node contains the thing_ob
DefaultMutableTreeNode node = (DefaultMutableTreeNode)tree.getModel().getRoot();
if (node.getUserObject().equals(thing_ob)) return node; // the root itself
final Enumeration<?> e = node.depthFirstEnumeration();
while (e.hasMoreElements()) {
node = (DefaultMutableTreeNode)e.nextElement();
if (node.getUserObject().equals(thing_ob)) {
//gotcha
return node;
}
}
}
return null;
}
/** Find the node in the tree that contains a Thing which contains the given project_ob. */
static public DefaultMutableTreeNode findNode2(final Object project_ob, final JTree tree) {
if (null != project_ob) {
DefaultMutableTreeNode node = (DefaultMutableTreeNode)tree.getModel().getRoot();
// check if it's the root itself
Object o = node.getUserObject();
if (null != o && o instanceof Thing && project_ob.equals(((Thing)o).getObject())) {
return node; // the root itself
}
final Enumeration<?> e = node.depthFirstEnumeration();
while (e.hasMoreElements()) {
node = (DefaultMutableTreeNode)e.nextElement();
o = node.getUserObject();
if (null != o && o instanceof Thing && project_ob == ((Thing)o).getObject()) {
//gotcha
return node;
}
}
}
return null;
}
/** Deselects whatever node is selected in the tree, and tries to select the one that contains the given object. */
static public void selectNode(final Object ob, final DNDTree tree) {
if (null == ob) {
Utils.log2("DNDTree.selectNode: null ob?");
return;
}
SwingUtilities.invokeLater(new Runnable() {
public void run() {
// deselect whatever is selected
tree.setSelectionPath(null);
final DefaultMutableTreeNode node = DNDTree.findNode(ob, tree);
if (null != node) {
final TreePath path = new TreePath(node.getPath());
try {
tree.scrollPathToVisible(path); // involves repaint, so must be set through invokeAndWait. Why it doesn't do so automatically is beyond me.
tree.setSelectionPath(path);
} catch (Exception e) {
Utils.log2("Swing, swing, until you hit the building in front.");
}
} else {
// Not found. But also occurs when adding a new profile/pipe/ball, because it is called 'setActive' on before adding it to the project tree.
//Utils.log("DNDTree.selectNode: not found for ob: " + ob);
}
}});
}
public void destroy() {
this.dtth.destroy();
this.dtth = null;
this.dispatcher.quit();
}
/** Overriding to fix synchronization issues: the path changes while the multithreaded swing attempts to repaint it, so we "invoke later". Hilarious. */
public void updateUILater() {
Utils.invokeLater(new Runnable() {
public void run() {
//try { Thread.sleep(200); } catch (InterruptedException ie) {}
try {
DNDTree.this.updateUI();
} catch (Exception e) {
IJError.print(e);
}
}
});
}
/** Rebuilds the entire tree, starting at the root Thing object. */
public void rebuild() {
rebuild((DefaultMutableTreeNode)this.getModel().getRoot(), false);
updateUILater();
}
/** Rebuilds the entire tree, starting at the given Thing object. */
public void rebuild(final Thing thing) {
rebuild(DNDTree.findNode(thing, this), false);
updateUILater();
}
/** Rebuilds the entire tree, from the given node downward. */
public void rebuild(final DefaultMutableTreeNode node, final boolean repaint) {
if (null == node) return;
if (0 != node.getChildCount()) node.removeAllChildren();
final Thing thing = (Thing)node.getUserObject();
final ArrayList<? extends Thing> al_children = thing.getChildren();
if (null == al_children) return;
for (Iterator<? extends Thing> it = al_children.iterator(); it.hasNext(); ) {
Thing child = it.next();
DefaultMutableTreeNode childnode = new DefaultMutableTreeNode(child);
node.add(childnode);
rebuild(childnode, false);
}
if (repaint) updateUILater();
}
/** Rebuilds the part of the tree under the given node, one level deep only, for reordering purposes. */
public void updateList(Thing thing) {
updateList(DNDTree.findNode(thing, this));
}
/** Rebuilds the part of the tree under the given node, one level deep only, for reordering purposes. */
public void updateList(DefaultMutableTreeNode node) {
if (null == node) return;
Thing thing = (Thing)node.getUserObject();
// store scrolling position for restoring purposes
Component c = this.getParent();
Point point = null;
if (c instanceof JScrollPane) {
point = ((JScrollPane)c).getViewport().getViewPosition();
}
// collect all current nodes
HashMap<Object,DefaultMutableTreeNode> ht = new HashMap<Object,DefaultMutableTreeNode>();
for (Enumeration<?> e = node.children(); e.hasMoreElements(); ) {
DefaultMutableTreeNode child_node = (DefaultMutableTreeNode)e.nextElement();
ht.put(child_node.getUserObject(), child_node);
}
// clear node
node.removeAllChildren();
// re-add nodes in the order present in the contained Thing
for (Iterator<? extends Thing> it = thing.getChildren().iterator(); it.hasNext(); ) {
Object ob_thing = it.next();
Object ob = ht.remove(ob_thing);
if (null == ob) {
Utils.log2("Adding missing node for " + ob_thing);
node.add(new DefaultMutableTreeNode(ob_thing));
continue;
}
node.add((DefaultMutableTreeNode)ob);
}
// consistency check: that all nodes have been re-added
if (0 != ht.size()) {
Utils.log2("WARNING DNDTree.updateList: did not end up adding this nodes:");
for (Iterator<?> it = ht.keySet().iterator(); it.hasNext(); ) {
Utils.log2(it.next().toString());
}
}
this.updateUILater();
// restore viewport position
if (null != point) {
((JScrollPane)c).getViewport().setViewPosition(point);
}
}
public DefaultMutableTreeNode getRootNode() {
return (DefaultMutableTreeNode)this.getModel().getRoot();
}
/** Does not incur in firing a TreeExpansion event, and affects the node only, not any of its parents. */
public void setExpandedSilently(final Thing thing, final boolean b) {
DefaultMutableTreeNode node = findNode(thing, this);
if (null == node) return;
setExpandedSilently(node, b);
}
static private final java.lang.reflect.Field f_expandedState = DNDTree.getExpandedStateField();
static private final java.lang.reflect.Field getExpandedStateField() {
try {
java.lang.reflect.Field f = JTree.class.getDeclaredField("expandedState");
f.setAccessible(true);
return f;
} catch (Exception e) {
Utils.log2("ERROR: " + e);
return null;
}
}
@SuppressWarnings("unchecked")
public void setExpandedSilently(final DefaultMutableTreeNode node, final boolean b) {
try {
final Hashtable<Object,Boolean> ht = (Hashtable<Object,Boolean>)f_expandedState.get(this);
ht.put(new TreePath(node.getPath()), b); // this queries directly the expandedState transient private Hashtable of the JTree
} catch (Exception e) {
Utils.log2("ERROR: " + e); // no IJError, potentially lots of text printed in failed applets
}
}
/** Get the map of Thing vs. expanded state for all nodes that have children. */
public HashMap<Thing,Boolean> getExpandedStates() {
return getExpandedStates(new HashMap<Thing,Boolean>());
}
/** Get the map of Thing vs. expanded state for all nodes that have children,
* and put the mappins into the {@code m}.
* @return {@code m}
*/
@SuppressWarnings("unchecked")
public HashMap<Thing,Boolean> getExpandedStates(final HashMap<Thing,Boolean> m) {
try {
final Hashtable<TreePath,Boolean> ht = (Hashtable<TreePath,Boolean>)f_expandedState.get(this);
for (final Map.Entry<TreePath,Boolean> e : ht.entrySet()) {
final Thing t = (Thing)((DefaultMutableTreeNode)e.getKey().getLastPathComponent()).getUserObject();
if (t.hasChildren()) m.put(t, e.getValue());
}
return m;
} catch (Exception e) {
IJError.print(e);
}
return null;
}
/** Check if there is a node holding the given Thing, and whether such node is expanded. */
public boolean isExpanded(final Thing thing) {
DefaultMutableTreeNode node = findNode(thing, this);
if (null == node) return false;
return isExpanded(node);
}
@SuppressWarnings("unchecked")
public boolean isExpanded(final DefaultMutableTreeNode node) {
try {
final Hashtable<Object,Boolean> ht = (Hashtable<Object,Boolean>)f_expandedState.get(this);
return Boolean.TRUE.equals(ht.get(new TreePath(node.getPath()))); // this queries directly the expandedState transient private HashMap of the JTree
} catch (Exception e) {
Utils.log2("ERROR: " + e); // no IJError, potentially lots of text printed in failed applets
return false;
}
/* // Java's idiotic API confuses isExpanded with isVisible, making them equal! The JTree.isVisible() check should not be done within the JTree.isExpanded() method!
DefaultMutableTreeNode node = findNode(thing, this);
Utils.log2("node is " + node);
if (null == node) return false;
TreePath path = new TreePath(node.getPath());
//return this.isExpanded(new TreePath(node.getPath())); // the API is ludicrous: isExpanded, isCollapsed and isVisible return a value relative to whether the node is visible, not its specific expanded state.
Utils.log("contains: " + hs_expanded_paths.contains(path));
//return hs_expanded_paths.contains(node.getPath());
for (Iterator it = hs_expanded_paths.iterator(); it.hasNext(); ) {
if (path.equals(it.next())) return true;
}
Utils.log2("thing not expanded: " + thing);
return false;
*/
}
//private HashSet hs_expanded_paths = new HashSet();
/** Sense node expansion events (the method name is missleading). */
public void treeCollapsed(TreeExpansionEvent tee) {
TreePath path = tee.getPath();
//hs_expanded_paths.remove(path);
updateInDatabase(path);
//Utils.log2("collapsed " + path);
}
public void treeExpanded(TreeExpansionEvent tee) {
TreePath path = tee.getPath();
//hs_expanded_paths.add(path);
updateInDatabase(path);
//Utils.log2("expanded " + path);
}
private void updateInDatabase(final TreePath path) {
DefaultMutableTreeNode node = (DefaultMutableTreeNode)path.getLastPathComponent();
ProjectThing thing = (ProjectThing)node.getUserObject(); // the Thing
thing.updateInDatabase(new StringBuffer("expanded='").append(isExpanded(thing)).append('\'').toString());
}
public String getInfo() {
DefaultMutableTreeNode node = (DefaultMutableTreeNode)this.getModel().getRoot();
int n_basic = 0, n_abstract = 0;
for (Enumeration<?> e = node.depthFirstEnumeration(); e.hasMoreElements(); ) {
DefaultMutableTreeNode child = (DefaultMutableTreeNode)e.nextElement();
Object ob = child.getUserObject();
if (ob instanceof Thing && Project.isBasicType(((Thing)ob).getType())) n_basic++;
else n_abstract++;
}
return this.getClass().getName() + ": \n\tAbstract nodes: " + n_abstract + "\n\tBasic nodes: " + n_basic + "\n";
}
// TODO: all these non-DND methods should go into an abstract child class that would be super of the trees proper
public final DefaultMutableTreeNode getRoot() {
return (DefaultMutableTreeNode)this.getModel().getRoot();
}
/** Appends at the end of the parent_node child list, and waits until the tree's UI is updated. */
protected DefaultMutableTreeNode addChild(final Thing child, final DefaultMutableTreeNode parent_node) {
try {
final DefaultMutableTreeNode node_child = new DefaultMutableTreeNode(child);
((DefaultTreeModel)getModel()).insertNodeInto(node_child, parent_node, parent_node.getChildCount());
try { DNDTree.this.updateUI(); } catch (Exception e) { IJError.print(e, true); }
return node_child;
} catch (Exception e) { IJError.print(e, true); }
return null;
}
/** Will add only those for which a node doesn't exist already. */
public void addLeafs(final java.util.List<? extends Thing> leafs, final Runnable after) {
javax.swing.SwingUtilities.invokeLater(new Runnable() { public void run() {
for (final Thing th : leafs) {
// find parent node
final DefaultMutableTreeNode parent = DNDTree.findNode(th.getParent(), DNDTree.this);
if (null == parent) {
Utils.log("Ignoring node " + th + " : null parent!");
continue;
}
// see if it exists already as a child of that node
boolean exists = false;
if (parent.getChildCount() > 0) {
final Enumeration<?> e = parent.children();
while (e.hasMoreElements()) {
DefaultMutableTreeNode child = (DefaultMutableTreeNode)e.nextElement();
if (child.getUserObject().equals(th)) {
exists = true;
break;
}
}
}
// otherwise add!
if (!exists) addChild(th, parent);
if (null != after) {
try {
after.run();
} catch (Throwable t) {
IJError.print(t);
}
}
}
}});
}
protected boolean removeNode(DefaultMutableTreeNode node) {
if (null == node) return false;
((DefaultTreeModel)this.getModel()).removeNodeFromParent(node);
this.updateUILater();
return true;
}
/** Shallow copy of the tree: returns a clone of the root node and cloned children, recursively, with all Thing cloned as well, but the Thing object is the same. */
public Thing duplicate(final HashMap<Thing,Boolean> expanded_state) {
DefaultMutableTreeNode root_node = (DefaultMutableTreeNode) this.getModel().getRoot();
// Descend both the root_copy tree and the root_node tree, and build shallow copies of Thing with same expanded state
return duplicate(root_node, expanded_state);
}
/** Returns the copy of the node's Thing. */
private Thing duplicate(final DefaultMutableTreeNode node, final HashMap<Thing,Boolean> expanded_state) {
Thing thing = (Thing) node.getUserObject();
Thing copy = thing.shallowCopy();
if (null != expanded_state) {
expanded_state.put(copy, isExpanded(node));
}
final Enumeration<?> e = node.children();
while (e.hasMoreElements()) {
DefaultMutableTreeNode child = (DefaultMutableTreeNode) e.nextElement();
copy.addChild(duplicate(child, expanded_state));
}
return copy;
}
/** Set the root Thing, and the expanded state of all nodes if {@code expanded_state} is not null.
* Used for restoring purposes from an undo step. */
public void reset(final HashMap<Thing,Boolean> expanded_state) {
// rebuild all nodes, restore their expansion state.
DefaultMutableTreeNode root_node = (DefaultMutableTreeNode) this.getModel().getRoot();
root_node.removeAllChildren();
set(root_node, getRootThing(), expanded_state);
updateUILater();
}
protected abstract Thing getRootThing();
/** Recursive */
protected void set(final DefaultMutableTreeNode root, final Thing root_thing, final HashMap<Thing,Boolean> expanded_state) {
root.setUserObject(root_thing);
final ArrayList<? extends Thing> al_children = root_thing.getChildren();
if (null != al_children) {
for (final Thing thing : al_children) {
DefaultMutableTreeNode child = new DefaultMutableTreeNode(thing);
root.add(child);
set(child, thing, expanded_state);
}
}
if (null != expanded_state) {
final Boolean b = expanded_state.get(root_thing);
if (null != b) setExpandedSilently(root, b.booleanValue());
}
}
public void keyPressed(final KeyEvent ke) {
if (!ke.getSource().equals(DNDTree.this) || !project.isInputEnabled()) {
ke.consume();
return;
}
int key_code = ke.getKeyCode();
switch (key_code) {
case KeyEvent.VK_S:
project.getLoader().saveTask(project, "Save");
ke.consume();
break;
}
}
public void keyReleased(KeyEvent ke) {}
public void keyTyped(KeyEvent ke) {}
}