001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.actions.mapmode;
003
004import static org.openstreetmap.josm.gui.help.HelpUtil.ht;
005import static org.openstreetmap.josm.tools.I18n.marktr;
006import static org.openstreetmap.josm.tools.I18n.tr;
007
008import java.awt.BasicStroke;
009import java.awt.Color;
010import java.awt.Cursor;
011import java.awt.Graphics2D;
012import java.awt.Point;
013import java.awt.event.KeyEvent;
014import java.awt.event.MouseEvent;
015import java.util.Collection;
016import java.util.Collections;
017import java.util.EnumMap;
018import java.util.EnumSet;
019import java.util.LinkedHashSet;
020import java.util.Map;
021import java.util.Optional;
022import java.util.Set;
023import java.util.stream.Stream;
024
025import javax.swing.JOptionPane;
026
027import org.openstreetmap.josm.Main;
028import org.openstreetmap.josm.data.Bounds;
029import org.openstreetmap.josm.data.SystemOfMeasurement;
030import org.openstreetmap.josm.data.coor.EastNorth;
031import org.openstreetmap.josm.data.osm.Node;
032import org.openstreetmap.josm.data.osm.OsmPrimitive;
033import org.openstreetmap.josm.data.osm.Way;
034import org.openstreetmap.josm.data.osm.WaySegment;
035import org.openstreetmap.josm.data.osm.visitor.paint.PaintColors;
036import org.openstreetmap.josm.data.preferences.AbstractToStringProperty;
037import org.openstreetmap.josm.data.preferences.BooleanProperty;
038import org.openstreetmap.josm.data.preferences.CachingProperty;
039import org.openstreetmap.josm.data.preferences.ColorProperty;
040import org.openstreetmap.josm.data.preferences.DoubleProperty;
041import org.openstreetmap.josm.data.preferences.IntegerProperty;
042import org.openstreetmap.josm.data.preferences.StrokeProperty;
043import org.openstreetmap.josm.gui.MapFrame;
044import org.openstreetmap.josm.gui.MapView;
045import org.openstreetmap.josm.gui.Notification;
046import org.openstreetmap.josm.gui.draw.MapViewPath;
047import org.openstreetmap.josm.gui.layer.Layer;
048import org.openstreetmap.josm.gui.layer.MapViewPaintable;
049import org.openstreetmap.josm.gui.layer.OsmDataLayer;
050import org.openstreetmap.josm.gui.util.ModifierListener;
051import org.openstreetmap.josm.tools.CheckParameterUtil;
052import org.openstreetmap.josm.tools.Geometry;
053import org.openstreetmap.josm.tools.ImageProvider;
054import org.openstreetmap.josm.tools.Shortcut;
055
056//// TODO: (list below)
057/* == Functionality ==
058 *
059 * 1. Use selected nodes as split points for the selected ways.
060 *
061 * The ways containing the selected nodes will be split and only the "inner"
062 * parts will be copied
063 *
064 * 2. Enter exact offset
065 *
066 * 3. Improve snapping
067 *
068 * 4. Visual cues could be better
069 *
070 * 5. (long term) Parallelize and adjust offsets of existing ways
071 *
072 * == Code quality ==
073 *
074 * a) The mode, flags, and modifiers might be updated more than necessary.
075 *
076 * Not a performance problem, but better if they where more centralized
077 *
078 * b) Extract generic MapMode services into a super class and/or utility class
079 *
080 * c) Maybe better to simply draw our own source way highlighting?
081 *
082 * Current code doesn't not take into account that ways might been highlighted
083 * by other than us. Don't think that situation should ever happen though.
084 */
085
086/**
087 * MapMode for making parallel ways.
088 *
089 * All calculations are done in projected coordinates.
090 *
091 * @author Ole Jørgen Brønner (olejorgenb)
092 */
093public class ParallelWayAction extends MapMode implements ModifierListener, MapViewPaintable {
094
095    private static final CachingProperty<BasicStroke> HELPER_LINE_STROKE = new StrokeProperty(prefKey("stroke.hepler-line"), "1").cached();
096    private static final CachingProperty<BasicStroke> REF_LINE_STROKE = new StrokeProperty(prefKey("stroke.ref-line"), "2 2 3").cached();
097
098    // @formatter:off
099    // CHECKSTYLE.OFF: SingleSpaceSeparator
100    private static final CachingProperty<Double> SNAP_THRESHOLD         = new DoubleProperty(prefKey("snap-threshold-percent"), 0.70).cached();
101    private static final CachingProperty<Boolean> SNAP_DEFAULT          = new BooleanProperty(prefKey("snap-default"),      true).cached();
102    private static final CachingProperty<Boolean> COPY_TAGS_DEFAULT     = new BooleanProperty(prefKey("copy-tags-default"), true).cached();
103    private static final CachingProperty<Integer> INITIAL_MOVE_DELAY    = new IntegerProperty(prefKey("initial-move-delay"), 200).cached();
104    private static final CachingProperty<Double> SNAP_DISTANCE_METRIC   = new DoubleProperty(prefKey("snap-distance-metric"), 0.5).cached();
105    private static final CachingProperty<Double> SNAP_DISTANCE_IMPERIAL = new DoubleProperty(prefKey("snap-distance-imperial"), 1).cached();
106    private static final CachingProperty<Double> SNAP_DISTANCE_CHINESE  = new DoubleProperty(prefKey("snap-distance-chinese"), 1).cached();
107    private static final CachingProperty<Double> SNAP_DISTANCE_NAUTICAL = new DoubleProperty(prefKey("snap-distance-nautical"), 0.1).cached();
108    private static final CachingProperty<Color> MAIN_COLOR = new ColorProperty(marktr("make parallel helper line"), (Color) null).cached();
109
110    private static final CachingProperty<Map<Modifier, Boolean>> SNAP_MODIFIER_COMBO
111            = new KeyboardModifiersProperty(prefKey("snap-modifier-combo"),             "?sC").cached();
112    private static final CachingProperty<Map<Modifier, Boolean>> COPY_TAGS_MODIFIER_COMBO
113            = new KeyboardModifiersProperty(prefKey("copy-tags-modifier-combo"),        "As?").cached();
114    private static final CachingProperty<Map<Modifier, Boolean>> ADD_TO_SELECTION_MODIFIER_COMBO
115            = new KeyboardModifiersProperty(prefKey("add-to-selection-modifier-combo"), "aSc").cached();
116    private static final CachingProperty<Map<Modifier, Boolean>> TOGGLE_SELECTED_MODIFIER_COMBO
117            = new KeyboardModifiersProperty(prefKey("toggle-selection-modifier-combo"), "asC").cached();
118    private static final CachingProperty<Map<Modifier, Boolean>> SET_SELECTED_MODIFIER_COMBO
119            = new KeyboardModifiersProperty(prefKey("set-selection-modifier-combo"),    "asc").cached();
120    // CHECKSTYLE.ON: SingleSpaceSeparator
121    // @formatter:on
122
123    private enum Mode {
124        DRAGGING, NORMAL
125    }
126
127    //// Preferences and flags
128    // See updateModeLocalPreferences for defaults
129    private Mode mode;
130    private boolean copyTags;
131
132    private boolean snap;
133
134    private final MapView mv;
135
136    // Mouse tracking state
137    private Point mousePressedPos;
138    private boolean mouseIsDown;
139    private long mousePressedTime;
140    private boolean mouseHasBeenDragged;
141
142    private transient WaySegment referenceSegment;
143    private transient ParallelWays pWays;
144    private transient Set<Way> sourceWays;
145    private EastNorth helperLineStart;
146    private EastNorth helperLineEnd;
147
148    /**
149     * Constructs a new {@code ParallelWayAction}.
150     * @param mapFrame Map frame
151     */
152    public ParallelWayAction(MapFrame mapFrame) {
153        super(tr("Parallel"), "parallel", tr("Make parallel copies of ways"),
154            Shortcut.registerShortcut("mapmode:parallel", tr("Mode: {0}",
155                tr("Parallel")), KeyEvent.VK_P, Shortcut.SHIFT),
156            mapFrame, ImageProvider.getCursor("normal", "parallel"));
157        putValue("help", ht("/Action/Parallel"));
158        mv = mapFrame.mapView;
159    }
160
161    @Override
162    public void enterMode() {
163        // super.enterMode() updates the status line and cursor so we need our state to be set correctly
164        setMode(Mode.NORMAL);
165        pWays = null;
166
167        super.enterMode();
168
169        mv.addMouseListener(this);
170        mv.addMouseMotionListener(this);
171        mv.addTemporaryLayer(this);
172
173        //// Needed to update the mouse cursor if modifiers are changed when the mouse is motionless
174        Main.map.keyDetector.addModifierListener(this);
175        sourceWays = new LinkedHashSet<>(getLayerManager().getEditDataSet().getSelectedWays());
176        for (Way w : sourceWays) {
177            w.setHighlighted(true);
178        }
179        mv.repaint();
180    }
181
182    @Override
183    public void exitMode() {
184        super.exitMode();
185        mv.removeMouseListener(this);
186        mv.removeMouseMotionListener(this);
187        mv.removeTemporaryLayer(this);
188        Main.map.statusLine.setDist(-1);
189        Main.map.statusLine.repaint();
190        Main.map.keyDetector.removeModifierListener(this);
191        removeWayHighlighting(sourceWays);
192        pWays = null;
193        sourceWays = null;
194        referenceSegment = null;
195        mv.repaint();
196    }
197
198    @Override
199    public String getModeHelpText() {
200        // TODO: add more detailed feedback based on modifier state.
201        // TODO: dynamic messages based on preferences. (Could be problematic translation wise)
202        switch (mode) {
203        case NORMAL:
204            // CHECKSTYLE.OFF: LineLength
205            return tr("Select ways as in Select mode. Drag selected ways or a single way to create a parallel copy (Alt toggles tag preservation)");
206            // CHECKSTYLE.ON: LineLength
207        case DRAGGING:
208            return tr("Hold Ctrl to toggle snapping");
209        }
210        return ""; // impossible ..
211    }
212
213    @Override
214    public boolean layerIsSupported(Layer layer) {
215        return layer instanceof OsmDataLayer;
216    }
217
218    @Override
219    public void modifiersChanged(int modifiers) {
220        if (Main.map == null || mv == null || !mv.isActiveLayerDrawable())
221            return;
222
223        // Should only get InputEvents due to the mask in enterMode
224        if (updateModifiersState(modifiers)) {
225            updateStatusLine();
226            updateCursor();
227        }
228    }
229
230    private boolean updateModifiersState(int modifiers) {
231        boolean oldAlt = alt, oldShift = shift, oldCtrl = ctrl;
232        updateKeyModifiers(modifiers);
233        return oldAlt != alt || oldShift != shift || oldCtrl != ctrl;
234    }
235
236    private void updateCursor() {
237        Cursor newCursor = null;
238        switch (mode) {
239        case NORMAL:
240            if (matchesCurrentModifiers(SET_SELECTED_MODIFIER_COMBO)) {
241                newCursor = ImageProvider.getCursor("normal", "parallel");
242            } else if (matchesCurrentModifiers(ADD_TO_SELECTION_MODIFIER_COMBO)) {
243                newCursor = ImageProvider.getCursor("normal", "parallel_add");
244            } else if (matchesCurrentModifiers(TOGGLE_SELECTED_MODIFIER_COMBO)) {
245                newCursor = ImageProvider.getCursor("normal", "parallel_remove");
246            }
247            break;
248        case DRAGGING:
249            newCursor = Cursor.getPredefinedCursor(Cursor.MOVE_CURSOR);
250            break;
251        default: throw new AssertionError();
252        }
253        if (newCursor != null) {
254            mv.setNewCursor(newCursor, this);
255        }
256    }
257
258    private void setMode(Mode mode) {
259        this.mode = mode;
260        updateCursor();
261        updateStatusLine();
262    }
263
264    private boolean sanityCheck() {
265        // @formatter:off
266        boolean areWeSane =
267            mv.isActiveLayerVisible() &&
268            mv.isActiveLayerDrawable() &&
269            ((Boolean) this.getValue("active"));
270        // @formatter:on
271        assert areWeSane; // mad == bad
272        return areWeSane;
273    }
274
275    @Override
276    public void mousePressed(MouseEvent e) {
277        requestFocusInMapView();
278        updateModifiersState(e.getModifiers());
279        // Other buttons are off limit, but we still get events.
280        if (e.getButton() != MouseEvent.BUTTON1)
281            return;
282
283        if (!sanityCheck())
284            return;
285
286        updateFlagsOnlyChangeableOnPress();
287        updateFlagsChangeableAlways();
288
289        // Since the created way is left selected, we need to unselect again here
290        if (pWays != null && pWays.getWays() != null) {
291            getLayerManager().getEditDataSet().clearSelection(pWays.getWays());
292            pWays = null;
293        }
294
295        mouseIsDown = true;
296        mousePressedPos = e.getPoint();
297        mousePressedTime = System.currentTimeMillis();
298
299    }
300
301    @Override
302    public void mouseReleased(MouseEvent e) {
303        updateModifiersState(e.getModifiers());
304        // Other buttons are off limit, but we still get events.
305        if (e.getButton() != MouseEvent.BUTTON1)
306            return;
307
308        if (!mouseHasBeenDragged) {
309            // use point from press or click event? (or are these always the same)
310            Way nearestWay = mv.getNearestWay(e.getPoint(), OsmPrimitive::isSelectable);
311            if (nearestWay == null) {
312                if (matchesCurrentModifiers(SET_SELECTED_MODIFIER_COMBO)) {
313                    clearSourceWays();
314                }
315                resetMouseTrackingState();
316                return;
317            }
318            boolean isSelected = nearestWay.isSelected();
319            if (matchesCurrentModifiers(ADD_TO_SELECTION_MODIFIER_COMBO)) {
320                if (!isSelected) {
321                    addSourceWay(nearestWay);
322                }
323            } else if (matchesCurrentModifiers(TOGGLE_SELECTED_MODIFIER_COMBO)) {
324                if (isSelected) {
325                    removeSourceWay(nearestWay);
326                } else {
327                    addSourceWay(nearestWay);
328                }
329            } else if (matchesCurrentModifiers(SET_SELECTED_MODIFIER_COMBO)) {
330                clearSourceWays();
331                addSourceWay(nearestWay);
332            } // else -> invalid modifier combination
333        } else if (mode == Mode.DRAGGING) {
334            clearSourceWays();
335        }
336
337        setMode(Mode.NORMAL);
338        resetMouseTrackingState();
339        mv.repaint();
340    }
341
342    private static void removeWayHighlighting(Collection<Way> ways) {
343        if (ways == null)
344            return;
345        for (Way w : ways) {
346            w.setHighlighted(false);
347        }
348    }
349
350    @Override
351    public void mouseDragged(MouseEvent e) {
352        // WTF.. the event passed here doesn't have button info?
353        // Since we get this event from other buttons too, we must check that
354        // _BUTTON1_ is down.
355        if (!mouseIsDown)
356            return;
357
358        boolean modifiersChanged = updateModifiersState(e.getModifiers());
359        updateFlagsChangeableAlways();
360
361        if (modifiersChanged) {
362            // Since this could be remotely slow, do it conditionally
363            updateStatusLine();
364            updateCursor();
365        }
366
367        if ((System.currentTimeMillis() - mousePressedTime) < INITIAL_MOVE_DELAY.get())
368            return;
369        // Assuming this event only is emitted when the mouse has moved
370        // Setting this after the check above means we tolerate clicks with some movement
371        mouseHasBeenDragged = true;
372
373        if (mode == Mode.NORMAL) {
374            // Should we ensure that the copyTags modifiers are still valid?
375
376            // Important to use mouse position from the press, since the drag
377            // event can come quite late
378            if (!isModifiersValidForDragMode())
379                return;
380            if (!initParallelWays(mousePressedPos, copyTags))
381                return;
382            setMode(Mode.DRAGGING);
383        }
384
385        // Calculate distance to the reference line
386        Point p = e.getPoint();
387        EastNorth enp = mv.getEastNorth((int) p.getX(), (int) p.getY());
388        EastNorth nearestPointOnRefLine = Geometry.closestPointToLine(referenceSegment.getFirstNode().getEastNorth(),
389                referenceSegment.getSecondNode().getEastNorth(), enp);
390
391        // Note: d is the distance in _projected units_
392        double d = enp.distance(nearestPointOnRefLine);
393        double realD = mv.getProjection().eastNorth2latlon(enp).greatCircleDistance(mv.getProjection().eastNorth2latlon(nearestPointOnRefLine));
394        double snappedRealD = realD;
395
396        boolean toTheRight = Geometry.angleIsClockwise(
397                referenceSegment.getFirstNode(), referenceSegment.getSecondNode(), new Node(enp));
398
399        if (snap) {
400            // TODO: Very simple snapping
401            // - Snap steps relative to the distance?
402            double snapDistance;
403            SystemOfMeasurement som = SystemOfMeasurement.getSystemOfMeasurement();
404            if (som.equals(SystemOfMeasurement.CHINESE)) {
405                snapDistance = SNAP_DISTANCE_CHINESE.get() * SystemOfMeasurement.CHINESE.aValue;
406            } else if (som.equals(SystemOfMeasurement.IMPERIAL)) {
407                snapDistance = SNAP_DISTANCE_IMPERIAL.get() * SystemOfMeasurement.IMPERIAL.aValue;
408            } else if (som.equals(SystemOfMeasurement.NAUTICAL_MILE)) {
409                snapDistance = SNAP_DISTANCE_NAUTICAL.get() * SystemOfMeasurement.NAUTICAL_MILE.aValue;
410            } else {
411                snapDistance = SNAP_DISTANCE_METRIC.get(); // Metric system by default
412            }
413            double closestWholeUnit;
414            double modulo = realD % snapDistance;
415            if (modulo < snapDistance/2.0) {
416                closestWholeUnit = realD - modulo;
417            } else {
418                closestWholeUnit = realD + (snapDistance-modulo);
419            }
420            if (Math.abs(closestWholeUnit - realD) < (SNAP_THRESHOLD.get() * snapDistance)) {
421                snappedRealD = closestWholeUnit;
422            } else {
423                snappedRealD = closestWholeUnit + Math.signum(realD - closestWholeUnit) * snapDistance;
424            }
425        }
426        d = snappedRealD * (d/realD); // convert back to projected distance. (probably ok on small scales)
427        helperLineStart = nearestPointOnRefLine;
428        helperLineEnd = enp;
429        if (toTheRight) {
430            d = -d;
431        }
432        pWays.changeOffset(d);
433
434        Main.map.statusLine.setDist(Math.abs(snappedRealD));
435        Main.map.statusLine.repaint();
436        mv.repaint();
437    }
438
439    private boolean matchesCurrentModifiers(CachingProperty<Map<Modifier, Boolean>> spec) {
440        return matchesCurrentModifiers(spec.get());
441    }
442
443    private boolean matchesCurrentModifiers(Map<Modifier, Boolean> spec) {
444        EnumSet<Modifier> modifiers = EnumSet.noneOf(Modifier.class);
445        if (ctrl) {
446            modifiers.add(Modifier.CTRL);
447        }
448        if (alt) {
449            modifiers.add(Modifier.ALT);
450        }
451        if (shift) {
452            modifiers.add(Modifier.SHIFT);
453        }
454        return spec.entrySet().stream().allMatch(entry -> modifiers.contains(entry.getKey()) == entry.getValue().booleanValue());
455    }
456
457    @Override
458    public void paint(Graphics2D g, MapView mv, Bounds bbox) {
459        if (mode == Mode.DRAGGING) {
460            CheckParameterUtil.ensureParameterNotNull(mv, "mv");
461
462            Color mainColor = MAIN_COLOR.get();
463            if (mainColor == null) {
464                mainColor = PaintColors.SELECTED.get();
465            }
466
467            // FIXME: should clip the line (gets insanely slow when zoomed in on a very long line
468            g.setStroke(REF_LINE_STROKE.get());
469            g.setColor(mainColor);
470            MapViewPath line = new MapViewPath(mv);
471            line.moveTo(referenceSegment.getFirstNode());
472            line.lineTo(referenceSegment.getSecondNode());
473            g.draw(line.computeClippedLine(g.getStroke()));
474
475            g.setStroke(HELPER_LINE_STROKE.get());
476            g.setColor(mainColor);
477            line = new MapViewPath(mv);
478            line.moveTo(helperLineStart);
479            line.lineTo(helperLineEnd);
480            g.draw(line.computeClippedLine(g.getStroke()));
481        }
482    }
483
484    private boolean isModifiersValidForDragMode() {
485        return (!alt && !shift && !ctrl) || matchesCurrentModifiers(SNAP_MODIFIER_COMBO)
486                || matchesCurrentModifiers(COPY_TAGS_MODIFIER_COMBO);
487    }
488
489    private void updateFlagsOnlyChangeableOnPress() {
490        copyTags = COPY_TAGS_DEFAULT.get().booleanValue() != matchesCurrentModifiers(COPY_TAGS_MODIFIER_COMBO);
491    }
492
493    private void updateFlagsChangeableAlways() {
494        snap = SNAP_DEFAULT.get().booleanValue() != matchesCurrentModifiers(SNAP_MODIFIER_COMBO);
495    }
496
497    // We keep the source ways and the selection in sync so the user can see the source way's tags
498    private void addSourceWay(Way w) {
499        assert sourceWays != null;
500        getLayerManager().getEditDataSet().addSelected(w);
501        w.setHighlighted(true);
502        sourceWays.add(w);
503    }
504
505    private void removeSourceWay(Way w) {
506        assert sourceWays != null;
507        getLayerManager().getEditDataSet().clearSelection(w);
508        w.setHighlighted(false);
509        sourceWays.remove(w);
510    }
511
512    private void clearSourceWays() {
513        assert sourceWays != null;
514        getLayerManager().getEditDataSet().clearSelection(sourceWays);
515        for (Way w : sourceWays) {
516            w.setHighlighted(false);
517        }
518        sourceWays.clear();
519    }
520
521    private void resetMouseTrackingState() {
522        mouseIsDown = false;
523        mousePressedPos = null;
524        mouseHasBeenDragged = false;
525    }
526
527    // TODO: rename
528    private boolean initParallelWays(Point p, boolean copyTags) {
529        referenceSegment = mv.getNearestWaySegment(p, OsmPrimitive::isUsable, true);
530        if (referenceSegment == null)
531            return false;
532
533        if (!sourceWays.contains(referenceSegment.way)) {
534            clearSourceWays();
535            addSourceWay(referenceSegment.way);
536        }
537
538        try {
539            int referenceWayIndex = -1;
540            int i = 0;
541            for (Way w : sourceWays) {
542                if (w == referenceSegment.way) {
543                    referenceWayIndex = i;
544                    break;
545                }
546                i++;
547            }
548            pWays = new ParallelWays(sourceWays, copyTags, referenceWayIndex);
549            pWays.commit();
550            getLayerManager().getEditDataSet().setSelected(pWays.getWays());
551            return true;
552        } catch (IllegalArgumentException e) {
553            Main.debug(e);
554            new Notification(tr("ParallelWayAction\n" +
555                    "The ways selected must form a simple branchless path"))
556                    .setIcon(JOptionPane.INFORMATION_MESSAGE)
557                    .show();
558            // The error dialog prevents us from getting the mouseReleased event
559            resetMouseTrackingState();
560            pWays = null;
561            return false;
562        }
563    }
564
565    private static String prefKey(String subKey) {
566        return "edit.make-parallel-way-action." + subKey;
567    }
568
569    /**
570     * A property that holds the keyboard modifiers.
571     * @author Michael Zangl
572     * @since 10869
573     */
574    private static class KeyboardModifiersProperty extends AbstractToStringProperty<Map<Modifier, Boolean>> {
575
576        KeyboardModifiersProperty(String key, String defaultValue) {
577            super(key, createFromString(defaultValue));
578        }
579
580        KeyboardModifiersProperty(String key, Map<Modifier, Boolean> defaultValue) {
581            super(key, defaultValue);
582        }
583
584        @Override
585        protected String toString(Map<Modifier, Boolean> t) {
586            StringBuilder sb = new StringBuilder();
587            for (Modifier mod : Modifier.values()) {
588                Boolean val = t.get(mod);
589                if (val == null) {
590                    sb.append('?');
591                } else if (val) {
592                    sb.append(Character.toUpperCase(mod.shortChar));
593                } else {
594                    sb.append(mod.shortChar);
595                }
596            }
597            return sb.toString();
598        }
599
600        @Override
601        protected Map<Modifier, Boolean> fromString(String string) {
602            return createFromString(string);
603        }
604
605        private static Map<Modifier, Boolean> createFromString(String string) {
606            Map<Modifier, Boolean> ret = new EnumMap<>(Modifier.class);
607            for (char c : string.toCharArray()) {
608                if (c == '?') {
609                    continue;
610                }
611                Optional<Modifier> mod = Modifier.findWithShortCode(c);
612                if (mod.isPresent()) {
613                    ret.put(mod.get(), Character.isUpperCase(c));
614                } else {
615                    Main.debug("Ignoring unknown modifier {0}", c);
616                }
617            }
618            return Collections.unmodifiableMap(ret);
619        }
620    }
621
622    private enum Modifier {
623        CTRL('c'),
624        ALT('a'),
625        SHIFT('s');
626
627        private final char shortChar;
628
629        Modifier(char shortChar) {
630            this.shortChar = Character.toLowerCase(shortChar);
631        }
632
633        /**
634         * Find the modifier with the given short code
635         * @param charCode The short code
636         * @return The modifier
637         */
638        public static Optional<Modifier> findWithShortCode(int charCode) {
639            return Stream.of(values()).filter(m -> m.shortChar == Character.toLowerCase(charCode)).findAny();
640        }
641    }
642}