/* * Copyright 2003-2010 Tufts University Licensed under the * Educational Community 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.osedu.org/licenses/ECL-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 tufts.vue; import tufts.Util; import java.util.*; import java.awt.geom.Rectangle2D; import java.awt.geom.Point2D; import java.awt.geom.RectangularShape; import com.google.common.collect.Multiset; import com.google.common.collect.HashMultiset; /** * * Maintains the VUE global list of selected LWComponent's. * * @version $Revision: 1.115 $ / $Date: 2010-02-03 19:17:41 $ / $Author: mike $ * @author Scott Fraize * */ // could have this listen to everyone in it, and then folks who want // to monitor the selected object can only once regiester as a // selectedEventListener, instead of always adding and removing from // the individual objects. public class LWSelection extends java.util.ArrayList<LWComponent> { private static final org.apache.log4j.Logger Log = org.apache.log4j.Logger.getLogger(LWSelection.class); public interface Acceptor extends tufts.vue.Acceptor<LWComponent> { /** @return true if the given object acceptable for selection */ public boolean accept(LWComponent c); } private List listeners = new java.util.ArrayList(); private List controlListeners = new java.util.LinkedList(); private Rectangle2D.Float mBounds = null; private LWSelection lastSelection; private boolean isClone = false; private int mWidth = -1, mHeight = -1; // only used for manually created selections private Object source; private LWComponent focal; // root of selection tree private String mDescription; private long mEditablePropertyKeys; private int mDataValueCount; private int mDataRowCount; /** optional style in which to record style property changes that have been applied to the selection */ private LWComponent mStyleRecord; private final Set<LWContainer> mParents = new java.util.HashSet() { @Override public boolean add(Object parent) { if (parent != null) return super.add(parent); else return false; } }; private final Multiset<Class> mTypes = HashMultiset.create(); private List<LWComponent> mSecureList = null; public LWSelection() {} public Object getSource() { return source; } public void setSource(Object src) { this.source = src; } public void setSelectionSourceFocal(LWComponent focal) { this.focal = focal; } public LWComponent getSelectionSourceFocal() { return focal; } public LWComponent getStyleRecord() { return mStyleRecord; } public void setStyleRecord(LWComponent c) { if (mStyleRecord != c) { Log.debug("setStyleRecord " + c); //Log.debug("setStyleRecord " + c, new Throwable("HERE")); mStyleRecord = c; } } private void clearStyleRecord() { setStyleRecord(null); } // currently only used for special case manually created selections public int getWidth() { return mWidth; } // currently only used for special case manually created selections public int getHeight() { return mHeight; } public boolean isSized() { return mWidth > 0 && mHeight > 0; } public void setSize(int w, int h) { mWidth = w; mHeight = h; } /** @return an unmodifable list of our contents useful for iteration */ public List<LWComponent> contents() { if (mSecureList == null) mSecureList = java.util.Collections.unmodifiableList(this); return mSecureList; } /** create a temporary selection that contains just the given component */ public LWSelection(LWComponent c) { //if (DEBUG.Enabled) tufts.Util.printStackTrace("selection singleton for " + c); isClone = true; addSilent(c); } /** create a temporary selection that contains just the given components. Does NOT auto-compute statistics. */ public LWSelection(java.util.List<? extends LWComponent> list) { isClone = true; super.addAll(list); } public interface Listener extends java.util.EventListener { void selectionChanged(LWSelection selection); } public static class Controller extends Point2D.Float { protected java.awt.Color color = VueConstants.COLOR_SELECTION_HANDLE; public Controller() {} public Controller(float x, float y) { super(x, y); } public Controller(java.awt.geom.Point2D p) { this((float)p.getX(), (float)p.getY()); } public void setColor(java.awt.Color c) { this.color = c; } public java.awt.Color getColor() { return color; } /** @return override for an optional shape (besides the default square) */ public RectangularShape getShape() { return null; } /** @return override for an optional rotation for the shape (in radians) */ public double getRotation() { return 0; } } public interface ControlListener extends java.util.EventListener { void controlPointPressed(int index, MapMouseEvent e); void controlPointMoved(int index, MapMouseEvent e); void controlPointDropped(int index, MapMouseEvent e); Controller[] getControlPoints(double zoom); } private void addControlListener(ControlListener listener) { if (DEBUG.SELECTION) debug("adding control listener " + listener); controlListeners.add(listener); } private void removeControlListener(ControlListener listener) { if (DEBUG.SELECTION) debug("removing control listener " + listener); if (!controlListeners.remove(listener)) throw new IllegalStateException(this + " didn't contain control listener " + listener); } java.util.List<ControlListener> getControlListeners() { return controlListeners; } public synchronized void addListener(Listener l) { if (DEBUG.SELECTION&&DEBUG.META) debug("adding listener " + l); listeners.add(l); } public synchronized void removeListener(Listener l) { if (DEBUG.SELECTION&&DEBUG.META) debug("removing listener " + l); listeners.remove(l); } public boolean inNotify() { return inNotify; } private Listener[] listener_buf = new Listener[128]; // todo: this a bit overkill private boolean inNotify = false; private synchronized void notifyListeners() { //if (isClone) throw new IllegalStateException(this + " clone's can't notify listeners! " + this); if (isClone) { if (DEBUG.Enabled) Log.debug(this + ": clone skipping notification"); return; } if (notifyUnderway()) return; try { inNotify = true; if (DEBUG.SELECTION || DEBUG.EVENTS) { if (DEBUG.SELECTION) System.out.println("\n-----------------------------------------------------------------------------"); debug("NOTIFYING " + listeners.size() + " LISTENERS"); //Log.debug("content summary: " + mTypes); } Listener[] listener_iter = (Listener[]) listeners.toArray(listener_buf); int nlistener = listeners.size(); long start = 0; for (int i = 0; i < nlistener; i++) { if (DEBUG.SELECTION && DEBUG.META) debug("notifying: #" + (i+1) + " " + (i<9?" ":"")); Listener l = listener_iter[i]; try { if (DEBUG.SELECTION || DEBUG.PERF) { //System.err.format("%70s...", Util.tags(l)); if (l instanceof java.awt.Component) System.err.format("%70s...", tufts.vue.gui.GUI.name(l)); else System.err.format("%70s...", Util.tags(l)); start = System.nanoTime(); } l.selectionChanged(this); if (DEBUG.SELECTION || DEBUG.PERF) { final long delta = System.nanoTime() - start; System.out.format("%6.1fms\n", delta / 100000.0); } } catch (Exception ex) { System.err.println(this + " notifyListeners: exception during selection change notification:" + "\n\tselection: " + this + "\n\tfailing listener: " + l); ex.printStackTrace(); //java.awt.Toolkit.getDefaultToolkit().beep(); } } postNotify(); } finally { inNotify = false; } } protected void postNotify() { if (size() == 1) VUE.setActive(LWComponent.class, this, first()); else VUE.setActive(LWComponent.class, this, null); } private boolean notifyUnderway() { if (inNotify) { Util.printStackTrace(this + " attempt to change selection during selection change notification: denied. src="+source); return true; } else { return false; } } /** for Actions.java */ java.util.List getListeners() { return this.listeners; } public synchronized void setTo(LWComponent c) { if (size() == 1 && first() == c) return; if (notifyUnderway()) return; clearSilent(); add(c); } // note: this not helpful as a public, as most sets immediately generate an event, // and we don't leave intermediate state about in the selection protected void setDescription(String s) { mDescription = s; } public String getDescription() { return mDescription; } public void setTo(Iterable bag) { setImpl(bag, null, "", null); } public void setWithStyle(Iterable bag, String description, LWComponent styleRecord) { //if (DEBUG.SELECTION||DEBUG.PERF) Log.debug("setTo: " + Util.tags(bag)); setImpl(bag, null, description, styleRecord); } public void setWithDescription(Iterable bag, String description) { setImpl(bag, null, description, null); } public void setTo(Iterator i) { setImpl(null, i, "", null); } private synchronized void setImpl(Iterable bag, Iterator i, String description, LWComponent styleRecord) { final Iterator iter = bag.iterator(); if (DEBUG.SELECTION) Log.debug("setImpl: " + Util.tags(description) + "\n\t bag: " + Util.tags(bag) + "\n\t bagIter: " + Util.tags(iter) + "\n\titerator: " + Util.tags(i) + "\n\t style: " + Util.tags(styleRecord)); if (notifyUnderway()) { if (DEBUG.SELECTION) Log.debug("setImpl: ABORTING, notify is underway"); return; } final boolean hadContents = !isEmpty(); if (i == null) i = iter; clearSilent(); setDescription(description); final boolean changed = addImpl(i); // won't notify setStyleRecord(styleRecord); // must do after add, but BEFORE notify if (changed || (hadContents && isEmpty())) { // we ended up changing the the selection by // clearing it out and then setting it to nothing notifyListeners(); } } @Override public synchronized boolean add(LWComponent c) { if (notifyUnderway()) return false; if (!c.isSelected()) { if (addSilent(c) && !isClone) { clearStyleRecord(); // any selection changes break association with the style record notifyListeners(); } } else { if (DEBUG.SELECTION) debug("addToSelection(already): " + c); return false; } return true; } //public boolean add(Object o) { //throw new RuntimeException(this + " can't add " + o.getClass() + ": " + o); //} /** Make sure all in Iterable are in selection & do a single change notify at the end */ public void add(Iterable<LWComponent> iterable) { add(iterable.iterator()); } /** Make sure all in iterator are in selection & do a single change notify at the end */ synchronized void add(Iterator<LWComponent> i) { if (notifyUnderway()) return; if (addImpl(i)) { clearStyleRecord(); // any selection changes break association with the style record notifyListeners(); } } /** @return true if anything was actually added */ private synchronized boolean addImpl(Iterator<LWComponent> i) { LWComponent c; boolean changed = false; while (i.hasNext()) { c = i.next(); if (!c.isSelected() && c.isDrawn()) { if (addSilent(c)) changed = true; } } if (DEBUG.SELECTION||DEBUG.PERF) Log.debug("addImpl: " + Util.tags(i) + "; completed"); return changed; } public void toggle(LWComponent c) { toggle(Util.iterable(c)); } /** Change the selection status of all LWComponents in iterator */ synchronized void toggle(Iterable<LWComponent> iterable) { if (notifyUnderway()) return; boolean changed = false; boolean removed = false; for (LWComponent c : iterable) { if (c.isSelected()) { changed = true; removed = true; removeSilent(c); } else { if (addSilent(c)) changed = true; } } if (removed) resetStatistics(); if (changed) notifyListeners(); } protected boolean isSelectable(LWComponent c) { return c instanceof LWMap.Layer == false; } private synchronized boolean addSilent(LWComponent c) { if (DEBUG.SELECTION && DEBUG.META /*|| DEBUG.EVENTS*/) debug("addSilent " + c + " src=" + source); if (c == null) { tufts.Util.printStackTrace("can't add null to a selection"); return false; } if (!isSelectable(c)) { Util.printStackTrace("not allowed to select " + c); return false; } if (notifyUnderway()) return false; if (!isClone && size() > 0) { // clones can break the rules if they want // special case for items such as LWMap or LWPathway. We do NOT allow // instances of these items to be added to the selection if there's anything else in // it, and if there's already a one of them in the selection, clear it out if we add // anything else. (because these items cannot be operated on with other // items on the map at the same time: e.g., dragged, or show their boundary) if (!first().supportsMultiSelection()) clearSilent(); else if (!c.supportsMultiSelection()) return false; // don't add } if (DEBUG.SELECTION) debug("add: " + c); if (!c.isSelected()) { if (!isClone) c.setSelected(true); super.add(c); mBounds = null; mTypes.add(c.getClass()); mParents.add(c.getParent()); mEditablePropertyKeys = 0; // set to recompute if (!isClone && c instanceof ControlListener) addControlListener((ControlListener)c); return true; } else throw new RuntimeException(this + " attempt to add already selected component " + c); } public synchronized void remove(LWComponent c) { clearStyleRecord(); // any selection changes break association with the style record removeSilent(c); resetStatistics(); notifyListeners(); } @Override public LWComponent[] toArray() { return super.toArray(new LWComponent[size()]); } /** find and remove from the selection any hidden/collapsed nodes */ public synchronized void clearHidden() { if (DEBUG.SELECTION) debug("clearHidden"); boolean removed = false; for (LWComponent c : toArray()) { try { if (c.isHidden() || c.isAncestorCollapsed() || c.getLayer().isHidden()) { if (DEBUG.SELECTION) Log.debug("clearHidden: clearing " + c); removeSilent(c); removed = true; } } catch (Throwable t) { Log.error("clearHidden processing " + c + " in selection " + this); } } if (removed) { resetStatistics(); notifyListeners(); } } /** Remove from selection anything that's been deleted */ public synchronized void clearDeleted() { // This is special case code called by the UndoManager during an undo whenever // there are any hierarchy changes. Would be cleaner to for the selection to // listen to all it's members for deletion events (expensive to always // add/remove all those listeners), or listen to the map for all deletion events // and and check if any are on our members. if (DEBUG.SELECTION) debug("clearDeleted"); boolean removed = false; for (LWComponent c : toArray()) { if (DEBUG.SELECTION) Log.debug("clearDeleted: checking " + c); if (c.isDeleted()) { if (DEBUG.SELECTION) Log.debug("clearDeleted: clearing " + c); removeSilent(c); removed = true; } } if (removed) { resetStatistics(); notifyListeners(); } } /** * special case: does not currently issue any notifications, tho * will recompute statistics -- selection must be cleared after * using this to be restored to a sane state */ public synchronized void clearAncestorSelected() { boolean removed = false; Iterator<LWComponent> i = iterator(); while (i.hasNext()) { final LWComponent c = i.next(); if (c.isAncestorSelected()) { i.remove(); c.setSelected(false); if (DEBUG.SELECTION) debug("removedAncestorSelected " + c); removed = true; } } if (removed) resetStatistics(); } private synchronized void removeSilent(LWComponent c) { if (DEBUG.SELECTION) debug("remove " + c); if (notifyUnderway()) return; if (!isClone) c.setSelected(false); if (!isClone && c instanceof ControlListener) removeControlListener((ControlListener)c); if (!super.remove(c)) throw new RuntimeException(this + " remove: list doesn't contain " + c); } /** * clearAndNotify * This emthod clears teh selection and always notifies * listeners of a change. * **/ public synchronized void clearAndNotify() { clearSilent(); if (DEBUG.SELECTION) debug("clearAndNotify: forced notification after clear"); notifyListeners(); } public synchronized void clear() { if (clearSilent()) notifyListeners(); } public synchronized void reselect() { if (lastSelection != null) { LWSelection reselecting = lastSelection; lastSelection = null; clearSilent(); // not working? Actually, at moment, is not working in // the first place -- someone disabled the display of the // description string in the info window -- was that on // purpose? setDescription(reselecting.getDescription()); // todo: also need to restore the STYLE-RECORD add(reselecting); } else clear(); } private synchronized boolean clearSilent() { mDescription = ""; clearStyleRecord(); if (isEmpty()) return false; if (notifyUnderway()) return false; if (DEBUG.SELECTION) { debug("clearSilent"); } if (!isClone) { for (LWComponent c : this) c.setSelected(false); lastSelection = clone(); } if (controlListeners != null) controlListeners.clear(); super.clear(); resetStatistics(); return true; } public void resetStatistics() { mBounds = null; mTypes.clear(); mParents.clear(); mEditablePropertyKeys = 0; // set to recompute mDataValueCount = 0; mDataRowCount = 0; if (size() > 0) { if (DEBUG.Enabled) debug("RECOMPUTING STATISTICS; n=" + size()); // TODO: ideally, we would listen for hierarchy change events on the // selection contents, and auto-recompute mParents whenever a change was // detected -- otherwise, mParents immediately becomes incorrect if a user // action reparents any of the selection contents. This currently has to be // watched for manually by calling resetStatistics (e.g., see LayersUI). for (LWComponent c : this) { mParents.add(c.getParent()); mTypes.add(c.getClass()); // todo: more efficient: if (c.isDataValueNode()) mDataValueCount++; else if (c.isDataRowNode()) mDataRowCount++; } } } /** return bounds of map selection in map (not screen) coordinates */ public Rectangle2D getBounds() { if (size() == 0) return null; //todo:not really safe to cache as we don't know if anything in has has moved? //if (bounds == null) { mBounds = LWMap.getBounds(iterator()); //System.out.println("COMPUTED SELECTION BOUNDS=" + bounds); //} if (isSized()) { mBounds.width = mWidth; mBounds.height = mHeight; } return mBounds; } // /** return shape bounds of map selection in map (not screen) coordinates // * Does NOT inclde any stroke widths. */ // public Rectangle2D getShapeBounds() // { // if (size() == 0) // return null; // return LWMap.getBounds(iterator()); // } void flushBounds() { mBounds = null; } public boolean contains(float mapX, float mapY) { if (size() == 0) return false; return getBounds().contains(mapX, mapY); } public LWComponent first() { return size() == 0 ? null : get(0); } /** @return the single component in the selection if there is only one, otherwise, null */ public LWComponent only() { return size() == 1 ? get(0) : null; } public LWComponent last() { return size() == 0 ? null : (LWComponent) get(size()-1); } public Set<Class> getTypes() { return mTypes.elementSet(); } public int getDataRowCount() { return mDataRowCount; } public int getDataValueCount() { return mDataValueCount; } public int count(Class<? extends LWComponent> clazz) { return mTypes.count(clazz); } public boolean containsType(Class clazz) { for (Class contentClass : getTypes()) if (clazz.isAssignableFrom(contentClass)) return true; return false; } public boolean allOfType(Class clazz) { for (Class contentClass : getTypes()) if (!clazz.isAssignableFrom(contentClass)) return false; return size() != 0; } public boolean allOfSameType() { return size() < 2 || getTypes().size() == 1; } public Set<LWContainer> getParents() { return mParents; } public boolean allHaveSameParent() { return mParents.size() < 2; } public boolean allHaveSameParentOfType(Class<? extends LWComponent> clazz) { return size() > 0 && allHaveSameParent() && clazz.isInstance(first().getParent()); } public boolean allHaveTopLevelParent() { for (LWContainer parent : mParents) if (!parent.isTopLevel()) return false; return true; } public LWComponent[] asArray() { LWComponent[] array = new LWComponent[size()]; super.toArray(array); return array; } public long getEditablePropertyBits() { if (mEditablePropertyKeys == 0 && !isEmpty()) { // keys may be zero if something was removed from the // selection, requiring us to recompute from scratch for (LWComponent c : this) mEditablePropertyKeys |= c.getSupportedPropertyBits(); } return mEditablePropertyKeys; } public boolean hasEditableProperty(Object key) { if (key instanceof LWComponent.Key) return ( getEditablePropertyBits() & ((LWComponent.Key)key).bit ) != 0; else return true; } @Override public LWSelection clone() { LWSelection copy = (LWSelection) super.clone(); copy.isClone = true; // if anybody tries to use these we want a NPE copy.listeners = null; copy.controlListeners = null; copy.mDescription = mDescription; // note that statistics are currently shared in the clone! return copy; } private void debug(String s) { Log.debug(String.format("[%s] %s", paramString(), s)); //Log.debug(String.format("[%s] %s", paramString(), s)); } protected String paramString() { final StringBuilder s = new StringBuilder(); if (isClone) { s.append(String.format("CLONE@%08x: ", System.identityHashCode(this))); } if (isEmpty()) { //s.append("EMPTY"); s.append("0"); } else { s.append(size()); s.append(": "); if (size() == 1) { s.append(first().toString()); } else { // size > 1 boolean first = true; for (Class clazz : getTypes()) { if (first) first = false; else s.append(", "); if (clazz.getName().startsWith("tufts.vue.LW")) { s.append(clazz.getSimpleName().substring(2)); } else s.append(clazz.getName()); s.append(" x "); s.append(mTypes.count(clazz)); } } } if (DEBUG.META) { s.append(' '); s.append(source); } return s.toString(); // return String.format("%d%s src=%s%s", size(), s, source, isClone ? " CLONE" : ""); } @Override public String toString() { // if (isClone || this != VUE.getSelection()) // return String.format("SELECTION@%08x[%s]", System.identityHashCode(this), paramString()); // else return String.format("LWSelection[%s]", paramString()); } }