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}