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