001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.Cursor;
007import java.awt.Point;
008import java.awt.event.ActionEvent;
009import java.awt.event.KeyEvent;
010import java.awt.event.MouseAdapter;
011import java.awt.event.MouseEvent;
012import java.awt.event.MouseWheelEvent;
013import java.util.ArrayList;
014import java.util.Optional;
015
016import javax.swing.AbstractAction;
017
018import org.openstreetmap.gui.jmapviewer.JMapViewer;
019import org.openstreetmap.josm.Main;
020import org.openstreetmap.josm.actions.mapmode.SelectAction;
021import org.openstreetmap.josm.spi.preferences.PreferenceChangeEvent;
022import org.openstreetmap.josm.spi.preferences.PreferenceChangedListener;
023import org.openstreetmap.josm.data.coor.EastNorth;
024import org.openstreetmap.josm.data.preferences.BooleanProperty;
025import org.openstreetmap.josm.gui.MapViewState.MapViewPoint;
026import org.openstreetmap.josm.spi.preferences.Config;
027import org.openstreetmap.josm.tools.Destroyable;
028import org.openstreetmap.josm.tools.Pair;
029import org.openstreetmap.josm.tools.Shortcut;
030
031/**
032 * Enables moving of the map by holding down the right mouse button and drag
033 * the mouse. Also, enables zooming by the mouse wheel.
034 *
035 * @author imi
036 */
037public class MapMover extends MouseAdapter implements Destroyable {
038
039    /**
040     * Zoom wheel is reversed.
041     */
042    public static final BooleanProperty PROP_ZOOM_REVERSE_WHEEL = new BooleanProperty("zoom.reverse-wheel", false);
043
044    static {
045        new JMapViewerUpdater();
046    }
047
048    private static class JMapViewerUpdater implements PreferenceChangedListener {
049
050        JMapViewerUpdater() {
051            Config.getPref().addPreferenceChangeListener(this);
052            updateJMapViewer();
053        }
054
055        @Override
056        public void preferenceChanged(PreferenceChangeEvent e) {
057            if (MapMover.PROP_ZOOM_REVERSE_WHEEL.getKey().equals(e.getKey())) {
058                updateJMapViewer();
059            }
060        }
061
062        private static void updateJMapViewer() {
063            JMapViewer.zoomReverseWheel = MapMover.PROP_ZOOM_REVERSE_WHEEL.get();
064        }
065    }
066
067    private final class ZoomerAction extends AbstractAction {
068        private final String action;
069
070        ZoomerAction(String action) {
071            this(action, "MapMover.Zoomer." + action);
072        }
073
074        ZoomerAction(String action, String name) {
075            this.action = action;
076            putValue(NAME, name);
077        }
078
079        @Override
080        public void actionPerformed(ActionEvent e) {
081            if (".".equals(action) || ",".equals(action)) {
082                Point mouse = Optional.ofNullable(nc.getMousePosition()).orElseGet(
083                    () -> new Point((int) nc.getBounds().getCenterX(), (int) nc.getBounds().getCenterY()));
084                mouseWheelMoved(new MouseWheelEvent(nc, e.getID(), e.getWhen(), e.getModifiers(), mouse.x, mouse.y, 0, false,
085                        MouseWheelEvent.WHEEL_UNIT_SCROLL, 1, ",".equals(action) ? -1 : 1));
086            } else {
087                EastNorth center = nc.getCenter();
088                EastNorth newcenter = nc.getEastNorth(nc.getWidth()/2+nc.getWidth()/5, nc.getHeight()/2+nc.getHeight()/5);
089                switch(action) {
090                case "left":
091                    nc.zoomTo(new EastNorth(2*center.east()-newcenter.east(), center.north()));
092                    break;
093                case "right":
094                    nc.zoomTo(new EastNorth(newcenter.east(), center.north()));
095                    break;
096                case "up":
097                    nc.zoomTo(new EastNorth(center.east(), 2*center.north()-newcenter.north()));
098                    break;
099                case "down":
100                    nc.zoomTo(new EastNorth(center.east(), newcenter.north()));
101                    break;
102                default: // Do nothing
103                }
104            }
105        }
106    }
107
108    /**
109     * The point in the map that was the under the mouse point
110     * when moving around started.
111     *
112     * This is <code>null</code> if movement is not active
113     */
114    private MapViewPoint mousePosMoveStart;
115
116    /**
117     * The map to move around.
118     */
119    private final NavigatableComponent nc;
120
121    private final ArrayList<Pair<ZoomerAction, Shortcut>> registeredShortcuts = new ArrayList<>();
122
123    /**
124     * Constructs a new {@code MapMover}.
125     * @param navComp the navigatable component
126     * @since 11713
127     */
128    public MapMover(NavigatableComponent navComp) {
129        this.nc = navComp;
130        nc.addMouseListener(this);
131        nc.addMouseMotionListener(this);
132        nc.addMouseWheelListener(this);
133
134        registerActionShortcut(new ZoomerAction("right"),
135                Shortcut.registerShortcut("system:movefocusright", tr("Map: {0}", tr("Move right")), KeyEvent.VK_RIGHT, Shortcut.CTRL));
136
137        registerActionShortcut(new ZoomerAction("left"),
138                Shortcut.registerShortcut("system:movefocusleft", tr("Map: {0}", tr("Move left")), KeyEvent.VK_LEFT, Shortcut.CTRL));
139
140        registerActionShortcut(new ZoomerAction("up"),
141                Shortcut.registerShortcut("system:movefocusup", tr("Map: {0}", tr("Move up")), KeyEvent.VK_UP, Shortcut.CTRL));
142        registerActionShortcut(new ZoomerAction("down"),
143                Shortcut.registerShortcut("system:movefocusdown", tr("Map: {0}", tr("Move down")), KeyEvent.VK_DOWN, Shortcut.CTRL));
144
145        // see #10592 - Disable these alternate shortcuts on OS X because of conflict with system shortcut
146        if (!Main.isPlatformOsx()) {
147            registerActionShortcut(new ZoomerAction(",", "MapMover.Zoomer.in"),
148                    Shortcut.registerShortcut("view:zoominalternate", tr("Map: {0}", tr("Zoom in")), KeyEvent.VK_COMMA, Shortcut.CTRL));
149
150            registerActionShortcut(new ZoomerAction(".", "MapMover.Zoomer.out"),
151                    Shortcut.registerShortcut("view:zoomoutalternate", tr("Map: {0}", tr("Zoom out")), KeyEvent.VK_PERIOD, Shortcut.CTRL));
152        }
153    }
154
155    private void registerActionShortcut(ZoomerAction action, Shortcut shortcut) {
156        MainApplication.registerActionShortcut(action, shortcut);
157        registeredShortcuts.add(new Pair<>(action, shortcut));
158    }
159
160    private boolean movementInProgress() {
161        return mousePosMoveStart != null;
162    }
163
164    /**
165     * If the right (and only the right) mouse button is pressed, move the map.
166     */
167    @Override
168    public void mouseDragged(MouseEvent e) {
169        int offMask = MouseEvent.BUTTON1_DOWN_MASK | MouseEvent.BUTTON2_DOWN_MASK;
170        boolean allowMovement = (e.getModifiersEx() & (MouseEvent.BUTTON3_DOWN_MASK | offMask)) == MouseEvent.BUTTON3_DOWN_MASK;
171        if (Main.isPlatformOsx()) {
172            MapFrame map = MainApplication.getMap();
173            int macMouseMask = MouseEvent.CTRL_DOWN_MASK | MouseEvent.BUTTON1_DOWN_MASK;
174            boolean macMovement = e.getModifiersEx() == macMouseMask;
175            boolean allowedMode = !map.mapModeSelect.equals(map.mapMode)
176                              || SelectAction.Mode.SELECT.equals(map.mapModeSelect.getMode());
177            allowMovement |= macMovement && allowedMode;
178        }
179        if (allowMovement) {
180            doMoveForDrag(e);
181        } else {
182            endMovement();
183        }
184    }
185
186    private void doMoveForDrag(MouseEvent e) {
187        if (!movementInProgress()) {
188            startMovement(e);
189        }
190        EastNorth center = nc.getCenter();
191        EastNorth mouseCenter = nc.getEastNorth(e.getX(), e.getY());
192        nc.zoomTo(mousePosMoveStart.getEastNorth().add(center).subtract(mouseCenter));
193    }
194
195    /**
196     * Start the movement, if it was the 3rd button (right button).
197     */
198    @Override
199    public void mousePressed(MouseEvent e) {
200        int offMask = MouseEvent.BUTTON1_DOWN_MASK | MouseEvent.BUTTON2_DOWN_MASK;
201        int macMouseMask = MouseEvent.CTRL_DOWN_MASK | MouseEvent.BUTTON1_DOWN_MASK;
202        if ((e.getButton() == MouseEvent.BUTTON3 && (e.getModifiersEx() & offMask) == 0) ||
203                (Main.isPlatformOsx() && e.getModifiersEx() == macMouseMask)) {
204            startMovement(e);
205        }
206    }
207
208    /**
209     * Change the cursor back to it's pre-move cursor.
210     */
211    @Override
212    public void mouseReleased(MouseEvent e) {
213        if (e.getButton() == MouseEvent.BUTTON3 || (Main.isPlatformOsx() && e.getButton() == MouseEvent.BUTTON1)) {
214            endMovement();
215        }
216    }
217
218    /**
219     * Start movement by setting a new cursor and remember the current mouse
220     * position.
221     * @param e The mouse event that leat to the movement from.
222     */
223    private void startMovement(MouseEvent e) {
224        if (movementInProgress()) {
225            return;
226        }
227        mousePosMoveStart = nc.getState().getForView(e.getX(), e.getY());
228        nc.setNewCursor(Cursor.MOVE_CURSOR, this);
229    }
230
231    /**
232     * End the movement. Setting back the cursor and clear the movement variables
233     */
234    private void endMovement() {
235        if (!movementInProgress()) {
236            return;
237        }
238        nc.resetCursor(this);
239        mousePosMoveStart = null;
240    }
241
242    /**
243     * Zoom the map by 1/5th of current zoom per wheel-delta.
244     * @param e The wheel event.
245     */
246    @Override
247    public void mouseWheelMoved(MouseWheelEvent e) {
248        int rotation = PROP_ZOOM_REVERSE_WHEEL.get() ? -e.getWheelRotation() : e.getWheelRotation();
249        nc.zoomManyTimes(e.getX(), e.getY(), rotation);
250    }
251
252    /**
253     * Emulates dragging on Mac OSX.
254     */
255    @Override
256    public void mouseMoved(MouseEvent e) {
257        if (!movementInProgress()) {
258            return;
259        }
260        // Mac OSX simulates with  ctrl + mouse 1  the second mouse button hence no dragging events get fired.
261        // Is only the selected mouse button pressed?
262        if (Main.isPlatformOsx()) {
263            if (e.getModifiersEx() == MouseEvent.CTRL_DOWN_MASK) {
264                doMoveForDrag(e);
265            } else {
266                endMovement();
267            }
268        }
269    }
270
271    @Override
272    public void destroy() {
273        for (Pair<ZoomerAction, Shortcut> shortcut : registeredShortcuts) {
274            MainApplication.unregisterActionShortcut(shortcut.a, shortcut.b);
275        }
276    }
277}