001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui;
003
004import java.awt.Color;
005import java.awt.Graphics2D;
006import java.awt.Point;
007import java.awt.Polygon;
008import java.awt.Rectangle;
009import java.awt.event.InputEvent;
010import java.awt.event.MouseEvent;
011import java.awt.event.MouseListener;
012import java.awt.event.MouseMotionListener;
013import java.beans.PropertyChangeEvent;
014import java.beans.PropertyChangeListener;
015import java.util.Collection;
016import java.util.LinkedList;
017
018import org.openstreetmap.josm.Main;
019import org.openstreetmap.josm.actions.SelectByInternalPointAction;
020import org.openstreetmap.josm.data.Bounds;
021import org.openstreetmap.josm.data.osm.Node;
022import org.openstreetmap.josm.data.osm.OsmPrimitive;
023import org.openstreetmap.josm.data.osm.Way;
024import org.openstreetmap.josm.data.osm.visitor.paint.PaintColors;
025import org.openstreetmap.josm.gui.layer.AbstractMapViewPaintable;
026import org.openstreetmap.josm.tools.Utils;
027
028/**
029 * Manages the selection of a rectangle or a lasso loop. Listening to left and right mouse button
030 * presses and to mouse motions and draw the rectangle accordingly.
031 *
032 * Left mouse button selects a rectangle from the press until release. Pressing
033 * right mouse button while left is still pressed enable the selection area to move
034 * around. Releasing the left button fires an action event to the listener given
035 * at constructor, except if the right is still pressed, which just remove the
036 * selection rectangle and does nothing.
037 *
038 * It is possible to switch between lasso selection and rectangle selection by using {@link #setLassoMode(boolean)}.
039 *
040 * The point where the left mouse button was pressed and the current mouse
041 * position are two opposite corners of the selection rectangle.
042 *
043 * For rectangle mode, it is possible to specify an aspect ratio (width per height) which the
044 * selection rectangle always must have. In this case, the selection rectangle
045 * will be the largest window with this aspect ratio, where the position the left
046 * mouse button was pressed and the corner of the current mouse position are at
047 * opposite sites (the mouse position corner is the corner nearest to the mouse
048 * cursor).
049 *
050 * When the left mouse button was released, an ActionEvent is send to the
051 * ActionListener given at constructor. The source of this event is this manager.
052 *
053 * @author imi
054 */
055public class SelectionManager implements MouseListener, MouseMotionListener, PropertyChangeListener {
056
057    /**
058     * This is the interface that an user of SelectionManager has to implement
059     * to get informed when a selection closes.
060     * @author imi
061     */
062    public interface SelectionEnded {
063        /**
064         * Called, when the left mouse button was released.
065         * @param r The rectangle that encloses the current selection.
066         * @param e The mouse event.
067         * @see InputEvent#getModifiersEx()
068         * @see SelectionManager#getSelectedObjects(boolean)
069         */
070        void selectionEnded(Rectangle r, MouseEvent e);
071
072        /**
073         * Called to register the selection manager for "active" property.
074         * @param listener The listener to register
075         */
076        void addPropertyChangeListener(PropertyChangeListener listener);
077
078        /**
079         * Called to remove the selection manager from the listener list
080         * for "active" property.
081         * @param listener The listener to register
082         */
083        void removePropertyChangeListener(PropertyChangeListener listener);
084    }
085
086    /**
087     * This draws the selection hint (rectangle or lasso polygon) on the screen.
088     *
089     * @author Michael Zangl
090     */
091    private class SelectionHintLayer extends AbstractMapViewPaintable {
092        @Override
093        public void paint(Graphics2D g, MapView mv, Bounds bbox) {
094            if (mousePos == null || mousePosStart == null || mousePos == mousePosStart)
095                return;
096            Color color = Utils.complement(PaintColors.getBackgroundColor());
097            g.setColor(color);
098            if (lassoMode) {
099                g.drawPolygon(lasso);
100
101                g.setColor(new Color(color.getRed(), color.getGreen(), color.getBlue(), color.getAlpha() / 8));
102                g.fillPolygon(lasso);
103            } else {
104                Rectangle paintRect = getSelectionRectangle();
105                g.drawRect(paintRect.x, paintRect.y, paintRect.width, paintRect.height);
106            }
107        }
108    }
109
110    /**
111     * The listener that receives the events after left mouse button is released.
112     */
113    private final SelectionEnded selectionEndedListener;
114    /**
115     * Position of the map when the mouse button was pressed.
116     * If this is not <code>null</code>, a rectangle/lasso line is drawn on screen.
117     * If this is <code>null</code>, no selection is active.
118     */
119    private Point mousePosStart;
120    /**
121     * The last position of the mouse while the mouse button was pressed.
122     */
123    private Point mousePos;
124    /**
125     * The Component that provides us with OSM data and the aspect is taken from.
126     */
127    private final NavigatableComponent nc;
128    /**
129     * Whether the selection rectangle must obtain the aspect ratio of the drawComponent.
130     */
131    private final boolean aspectRatio;
132
133    /**
134     * <code>true</code> if we should paint a lasso instead of a rectangle.
135     */
136    private boolean lassoMode;
137    /**
138     * The polygon to store the selection outline if {@link #lassoMode} is used.
139     */
140    private final Polygon lasso = new Polygon();
141
142    /**
143     * The result of the last selection.
144     */
145    private Polygon selectionResult = new Polygon();
146
147    private final SelectionHintLayer selectionHintLayer = new SelectionHintLayer();
148
149    /**
150     * Create a new SelectionManager.
151     *
152     * @param selectionEndedListener The action listener that receives the event when
153     *      the left button is released.
154     * @param aspectRatio If true, the selection window must obtain the aspect
155     *      ratio of the drawComponent.
156     * @param navComp The component that provides us with OSM data and the aspect is taken from.
157     */
158    public SelectionManager(SelectionEnded selectionEndedListener, boolean aspectRatio, NavigatableComponent navComp) {
159        this.selectionEndedListener = selectionEndedListener;
160        this.aspectRatio = aspectRatio;
161        this.nc = navComp;
162    }
163
164    /**
165     * Register itself at the given event source and add a hint layer.
166     * @param eventSource The emitter of the mouse events.
167     * @param lassoMode {@code true} to enable lasso mode, {@code false} to disable it.
168     */
169    public void register(MapView eventSource, boolean lassoMode) {
170       this.lassoMode = lassoMode;
171        eventSource.addMouseListener(this);
172        eventSource.addMouseMotionListener(this);
173        selectionEndedListener.addPropertyChangeListener(this);
174        eventSource.addPropertyChangeListener("scale", new PropertyChangeListener() {
175            @Override
176            public void propertyChange(PropertyChangeEvent evt) {
177                abortSelecting();
178            }
179        });
180        eventSource.addTemporaryLayer(selectionHintLayer);
181    }
182
183    /**
184     * Unregister itself from the given event source and hide the selection hint layer.
185     *
186     * @param eventSource The emitter of the mouse events.
187     */
188    public void unregister(MapView eventSource) {
189        abortSelecting();
190        eventSource.removeTemporaryLayer(selectionHintLayer);
191        eventSource.removeMouseListener(this);
192        eventSource.removeMouseMotionListener(this);
193        selectionEndedListener.removePropertyChangeListener(this);
194    }
195
196    /**
197     * If the correct button, from the "drawing rectangle" mode
198     */
199    @Override
200    public void mousePressed(MouseEvent e) {
201        if (e.getButton() == MouseEvent.BUTTON1 && e.getClickCount() > 1 && Main.main.getCurrentDataSet() != null) {
202            SelectByInternalPointAction.performSelection(Main.map.mapView.getEastNorth(e.getX(), e.getY()),
203                    (e.getModifiersEx() & MouseEvent.SHIFT_DOWN_MASK) != 0,
204                    (e.getModifiersEx() & MouseEvent.CTRL_DOWN_MASK) != 0);
205        } else if (e.getButton() == MouseEvent.BUTTON1) {
206            mousePosStart = mousePos = e.getPoint();
207
208            lasso.reset();
209            lasso.addPoint(mousePosStart.x, mousePosStart.y);
210        }
211    }
212
213    /**
214     * If the correct button is hold, draw the rectangle.
215     */
216    @Override
217    public void mouseDragged(MouseEvent e) {
218        int buttonPressed = e.getModifiersEx() & (MouseEvent.BUTTON1_DOWN_MASK | MouseEvent.BUTTON3_DOWN_MASK);
219
220        if (buttonPressed != 0) {
221            if (mousePosStart == null) {
222                mousePosStart = mousePos = e.getPoint();
223            }
224            selectionAreaChanged();
225        }
226
227        if (buttonPressed == MouseEvent.BUTTON1_DOWN_MASK) {
228            mousePos = e.getPoint();
229            addLassoPoint(e.getPoint());
230            selectionAreaChanged();
231        } else if (buttonPressed == (MouseEvent.BUTTON1_DOWN_MASK | MouseEvent.BUTTON3_DOWN_MASK)) {
232            moveSelection(e.getX()-mousePos.x, e.getY()-mousePos.y);
233            mousePos = e.getPoint();
234            selectionAreaChanged();
235        }
236    }
237
238    /**
239     * Moves the current selection by some pixels.
240     * @param dx How much to move it in x direction.
241     * @param dy How much to move it in y direction.
242     */
243    private void moveSelection(int dx, int dy) {
244        mousePosStart.x += dx;
245        mousePosStart.y += dy;
246        lasso.translate(dx, dy);
247    }
248
249    /**
250     * Check the state of the keys and buttons and set the selection accordingly.
251     */
252    @Override
253    public void mouseReleased(MouseEvent e) {
254        if (e.getButton() == MouseEvent.BUTTON1) {
255            endSelecting(e);
256        }
257    }
258
259    /**
260     * Ends the selection of the current area. This simulates a release of mouse button 1.
261     * @param e A mouse event that caused this. Needed for backward compatibility.
262     */
263    public void endSelecting(MouseEvent e) {
264        mousePos = e.getPoint();
265        if (lassoMode) {
266            addLassoPoint(e.getPoint());
267        }
268
269        // Left mouse was released while right is still pressed.
270        boolean rightMouseStillPressed = (e.getModifiersEx() & MouseEvent.BUTTON3_DOWN_MASK) != 0;
271
272        if (!rightMouseStillPressed) {
273            selectingDone(e);
274        }
275        abortSelecting();
276    }
277
278    private void addLassoPoint(Point point) {
279        if (isNoSelection()) {
280            return;
281        }
282        lasso.addPoint(point.x, point.y);
283    }
284
285    private boolean isNoSelection() {
286        return mousePos == null || mousePosStart == null || mousePos == mousePosStart;
287    }
288
289    /**
290     * Calculate and return the current selection rectangle
291     * @return A rectangle that spans from mousePos to mouseStartPos
292     */
293    private Rectangle getSelectionRectangle() {
294        int x = mousePosStart.x;
295        int y = mousePosStart.y;
296        int w = mousePos.x - mousePosStart.x;
297        int h = mousePos.y - mousePosStart.y;
298        if (w < 0) {
299            x += w;
300            w = -w;
301        }
302        if (h < 0) {
303            y += h;
304            h = -h;
305        }
306
307        if (aspectRatio) {
308            /* Keep the aspect ratio by growing the rectangle; the
309             * rectangle is always under the cursor. */
310            double aspectRatio = (double) nc.getWidth()/nc.getHeight();
311            if ((double) w/h < aspectRatio) {
312                int neww = (int) (h*aspectRatio);
313                if (mousePos.x < mousePosStart.x) {
314                    x += w - neww;
315                }
316                w = neww;
317            } else {
318                int newh = (int) (w/aspectRatio);
319                if (mousePos.y < mousePosStart.y) {
320                    y += h - newh;
321                }
322                h = newh;
323            }
324        }
325
326        return new Rectangle(x, y, w, h);
327    }
328
329    /**
330     * If the action goes inactive, remove the selection rectangle from screen
331     */
332    @Override
333    public void propertyChange(PropertyChangeEvent evt) {
334        if ("active".equals(evt.getPropertyName()) && !(Boolean) evt.getNewValue()) {
335            abortSelecting();
336        }
337    }
338
339    /**
340     * Stores the  current selection and stores the result in {@link #selectionResult} to  be retrieved by
341     * {@link #getSelectedObjects(boolean)} later.
342     * @param e The mouse event that caused the selection to be finished.
343     */
344    private void selectingDone(MouseEvent e) {
345        if (isNoSelection()) {
346            // Nothing selected.
347            return;
348        }
349        Rectangle r;
350        if (lassoMode) {
351            r = lasso.getBounds();
352
353            selectionResult = new Polygon(lasso.xpoints, lasso.ypoints, lasso.npoints);
354        } else {
355            r = getSelectionRectangle();
356
357            selectionResult = rectToPolygon(r);
358        }
359        selectionEndedListener.selectionEnded(r, e);
360    }
361
362    private void abortSelecting() {
363        if (mousePosStart != null) {
364            mousePos = mousePosStart = null;
365            lasso.reset();
366            selectionAreaChanged();
367        }
368    }
369
370    private void selectionAreaChanged() {
371        selectionHintLayer.invalidate();
372    }
373
374    /**
375     * Return a list of all objects in the active/last selection, respecting the different
376     * modifier.
377     *
378     * @param alt Whether the alt key was pressed, which means select all
379     * objects that are touched, instead those which are completely covered.
380     * @return The collection of selected objects.
381     */
382    public Collection<OsmPrimitive> getSelectedObjects(boolean alt) {
383        Collection<OsmPrimitive> selection = new LinkedList<>();
384
385        // whether user only clicked, not dragged.
386        boolean clicked = false;
387        Rectangle bounding = selectionResult.getBounds();
388        if (bounding.height <= 2 && bounding.width <= 2) {
389            clicked = true;
390        }
391
392        if (clicked) {
393            Point center = new Point(selectionResult.xpoints[0], selectionResult.ypoints[0]);
394            OsmPrimitive osm = nc.getNearestNodeOrWay(center, OsmPrimitive.isSelectablePredicate, false);
395            if (osm != null) {
396                selection.add(osm);
397            }
398        } else {
399            // nodes
400            for (Node n : nc.getCurrentDataSet().getNodes()) {
401                if (n.isSelectable() && selectionResult.contains(nc.getPoint2D(n))) {
402                    selection.add(n);
403                }
404            }
405
406            // ways
407            for (Way w : nc.getCurrentDataSet().getWays()) {
408                if (!w.isSelectable() || w.getNodesCount() == 0) {
409                    continue;
410                }
411                if (alt) {
412                    for (Node n : w.getNodes()) {
413                        if (!n.isIncomplete() && selectionResult.contains(nc.getPoint2D(n))) {
414                            selection.add(w);
415                            break;
416                        }
417                    }
418                } else {
419                    boolean allIn = true;
420                    for (Node n : w.getNodes()) {
421                        if (!n.isIncomplete() && !selectionResult.contains(nc.getPoint(n))) {
422                            allIn = false;
423                            break;
424                        }
425                    }
426                    if (allIn) {
427                        selection.add(w);
428                    }
429                }
430            }
431        }
432        return selection;
433    }
434
435    private static Polygon rectToPolygon(Rectangle r) {
436        Polygon poly = new Polygon();
437
438        poly.addPoint(r.x, r.y);
439        poly.addPoint(r.x, r.y + r.height);
440        poly.addPoint(r.x + r.width, r.y + r.height);
441        poly.addPoint(r.x + r.width, r.y);
442
443        return poly;
444    }
445
446    /**
447     * Enables or disables the lasso mode.
448     * @param lassoMode {@code true} to enable lasso mode, {@code false} to disable it.
449     */
450    public void setLassoMode(boolean lassoMode) {
451        this.lassoMode = lassoMode;
452    }
453
454    @Override
455    public void mouseClicked(MouseEvent e) {
456        // Do nothing
457    }
458
459    @Override
460    public void mouseEntered(MouseEvent e) {
461        // Do nothing
462    }
463
464    @Override
465    public void mouseExited(MouseEvent e) {
466        // Do nothing
467    }
468
469    @Override
470    public void mouseMoved(MouseEvent e) {
471        // Do nothing
472    }
473}