From 1aab326ce5855608cbd0d2939135297f2171aa64 Mon Sep 17 00:00:00 2001 From: Florian Kargl Date: Wed, 10 Aug 2022 23:20:17 +0200 Subject: [PATCH] Create new split mode Add a new mode for quick splitting of ways by selecting split points via mouse click. Ambiguous split actions can be resolved by choosing the correct way in a popup dialog. --- resources/images/{ => mapmode}/splitway.svg | 0 .../josm/actions/SplitWayAction.java | 71 +++- .../josm/actions/mapmode/SplitMode.java | 351 ++++++++++++++++++ src/org/openstreetmap/josm/gui/MapFrame.java | 9 +- .../josm/gui/util/HighlightHelper.java | 20 + 5 files changed, 440 insertions(+), 11 deletions(-) rename resources/images/{ => mapmode}/splitway.svg (100%) create mode 100644 src/org/openstreetmap/josm/actions/mapmode/SplitMode.java diff --git a/resources/images/splitway.svg b/resources/images/mapmode/splitway.svg similarity index 100% rename from resources/images/splitway.svg rename to resources/images/mapmode/splitway.svg diff --git a/src/org/openstreetmap/josm/actions/SplitWayAction.java b/src/org/openstreetmap/josm/actions/SplitWayAction.java index 10041c8f1e5..01ddc3bc269 100644 --- a/src/org/openstreetmap/josm/actions/SplitWayAction.java +++ b/src/org/openstreetmap/josm/actions/SplitWayAction.java @@ -18,6 +18,7 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; +import javax.swing.AbstractAction; import javax.swing.DefaultListCellRenderer; import javax.swing.JLabel; import javax.swing.JList; @@ -49,6 +50,7 @@ import org.openstreetmap.josm.gui.MapFrame; import org.openstreetmap.josm.gui.Notification; import org.openstreetmap.josm.tools.GBC; +import org.openstreetmap.josm.tools.ImageProvider; import org.openstreetmap.josm.tools.Shortcut; import org.openstreetmap.josm.tools.Utils; @@ -64,7 +66,7 @@ public class SplitWayAction extends JosmAction { * Create a new SplitWayAction. */ public SplitWayAction() { - super(tr("Split Way"), "splitway", tr("Split a way at the selected node."), + super(tr("Split Way"), "mapmode/splitway", tr("Split a way at the selected node."), Shortcut.registerShortcut("tools:splitway", tr("Tools: {0}", tr("Split Way")), KeyEvent.VK_P, Shortcut.DIRECT), true); setHelpId(ht("/Action/SplitWay")); } @@ -146,16 +148,27 @@ public static void runOn(DataSet ds) { // Finally, applicableWays contains only one perfect way final Way selectedWay = applicableWays.get(0); - final List> wayChunks = SplitWayCommand.buildSplitChunks(selectedWay, selectedNodes); - if (wayChunks != null) { - final List sel = new ArrayList<>(ds.getSelectedRelations()); - sel.addAll(selectedWays); + final List sel = new ArrayList<>(ds.getSelectedRelations()); + sel.addAll(selectedWays); + doSplitWayShowSegmentSelection(selectedWay, selectedNodes, sel); + } - final List newWays = SplitWayCommand.createNewWaysFromChunks(selectedWay, wayChunks); + /** + * Perform way splitting after presenting the user with a choice which way segment history should be preserved (in expert mode) + * @param splitWay The way to split + * @param splitNodes The nodes at which the way should be split + * @param selection (Optional) selection which should be updated + * + * @since xxx + */ + public static void doSplitWayShowSegmentSelection(Way splitWay, List splitNodes, List selection) { + final List> wayChunks = SplitWayCommand.buildSplitChunks(splitWay, splitNodes); + if (wayChunks != null) { + final List newWays = SplitWayCommand.createNewWaysFromChunks(splitWay, wayChunks); final Way wayToKeep = SplitWayCommand.Strategy.keepLongestChunk().determineWayToKeep(newWays); - if (ExpertToggleAction.isExpert() && !selectedWay.isNew()) { - final ExtendedDialog dialog = new SegmentToKeepSelectionDialog(selectedWay, newWays, wayToKeep, selectedNodes, sel); + if (ExpertToggleAction.isExpert() && !splitWay.isNew()) { + final ExtendedDialog dialog = new SegmentToKeepSelectionDialog(splitWay, newWays, wayToKeep, splitNodes, selection); dialog.toggleEnable("way.split.segment-selection-dialog"); if (!dialog.toggleCheckState()) { dialog.setModal(false); @@ -164,11 +177,51 @@ public static void runOn(DataSet ds) { } } if (wayToKeep != null) { - doSplitWay(selectedWay, wayToKeep, newWays, sel); + doSplitWay(splitWay, wayToKeep, newWays, selection); } } } + /** + * Split a specified {@link Way} at the given nodes + * + * Does not attempt to figure out which ways to split based on selection like {@link SplitWayAction} + * and instead works on specified ways given in constructor + * + * @since xxx + */ + public static class SplitWayActionConcrete extends AbstractAction { + + private Way splitWay; + private List splitNodes; + private List selection; + + /** + * Construct an action to split way {@code splitWay} at nodes {@code splitNodes} + * @param splitWay The way to split + * @param splitNodes The nodes the way should be split at + * @param selection (Optional, can be null) Selection which should be updated + */ + public SplitWayActionConcrete(Way splitWay, List splitNodes, List selection) { + super(tr("Split way {0}", DefaultNameFormatter.getInstance().format(splitWay)), + ImageProvider.get(splitWay.getType())); + putValue(SHORT_DESCRIPTION, getValue(NAME)); + this.splitWay = splitWay; + this.splitNodes = splitNodes; + this.selection = selection; + } + + @Override + public void actionPerformed(ActionEvent e) { + doSplitWayShowSegmentSelection(splitWay, splitNodes, selection); + } + + @Override + public boolean isEnabled() { + return !splitWay.getDataSet().isLocked(); + } + } + /** * A dialog to query which way segment should reuse the history of the way to split. */ diff --git a/src/org/openstreetmap/josm/actions/mapmode/SplitMode.java b/src/org/openstreetmap/josm/actions/mapmode/SplitMode.java new file mode 100644 index 00000000000..7531e49e513 --- /dev/null +++ b/src/org/openstreetmap/josm/actions/mapmode/SplitMode.java @@ -0,0 +1,351 @@ +// License: GPL. For details, see LICENSE file. +package org.openstreetmap.josm.actions.mapmode; + +import static org.openstreetmap.josm.tools.I18n.tr; + +import java.awt.Component; +import java.awt.Point; +import java.awt.event.FocusAdapter; +import java.awt.event.FocusEvent; +import java.awt.event.KeyEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import javax.swing.BorderFactory; +import javax.swing.JMenuItem; +import javax.swing.JOptionPane; +import javax.swing.JPopupMenu; +import javax.swing.border.Border; +import javax.swing.border.TitledBorder; + +import org.openstreetmap.josm.actions.SplitWayAction; +import org.openstreetmap.josm.actions.SplitWayAction.SplitWayActionConcrete; +import org.openstreetmap.josm.data.osm.DataSet; +import org.openstreetmap.josm.data.osm.DefaultNameFormatter; +import org.openstreetmap.josm.data.osm.Node; +import org.openstreetmap.josm.data.osm.OsmPrimitive; +import org.openstreetmap.josm.data.osm.Way; +import org.openstreetmap.josm.data.preferences.BooleanProperty; +import org.openstreetmap.josm.data.preferences.CachingProperty; +import org.openstreetmap.josm.gui.MainApplication; +import org.openstreetmap.josm.gui.MapView; +import org.openstreetmap.josm.gui.MenuScroller; +import org.openstreetmap.josm.gui.Notification; +import org.openstreetmap.josm.gui.util.HighlightHelper; +import org.openstreetmap.josm.tools.ImageProvider; +import org.openstreetmap.josm.tools.Shortcut; +import org.openstreetmap.josm.tools.Utils; + +/** + * Map mode for splitting ways. + * + * @since xxx + */ +public class SplitMode extends MapMode { + + /** Prioritized selected ways over others when splitting */ + static final CachingProperty PREFER_SELECTED_WAYS + = new BooleanProperty("split-mode.prefer-selected-ways", true).cached(); + + /** Don't consider disabled ways */ + static final CachingProperty IGNORE_DISABLED_WAYS + = new BooleanProperty("split-mode.ignore-disabled-ways", true).cached(); + + /** Helper to keep track of highlighted primitives */ + HighlightHelper highlight = new HighlightHelper(); + + /** + * Construct a new SplitMode object + */ + public SplitMode() { + super(tr("Split mode"), "splitway", tr("Split ways"), + Shortcut.registerShortcut("mapmode:split", tr("Mode: {0}", tr("Split mode")), KeyEvent.VK_T, Shortcut.DIRECT), + ImageProvider.getCursor("crosshair", null)); + } + + @Override + public void enterMode() { + super.enterMode(); + MapView mv = MainApplication.getMap().mapView; + mv.addMouseListener(this); + mv.addMouseMotionListener(this); + } + + @Override + public void exitMode() { + super.exitMode(); + MapView mv = MainApplication.getMap().mapView; + mv.removeMouseMotionListener(this); + mv.removeMouseListener(this); + removeHighlighting(); + } + + @Override + public void mousePressed(MouseEvent e) { + super.mousePressed(e); + + MapView mv = MainApplication.getMap().mapView; + int mouseDownButton = e.getButton(); + Point mousePos = e.getPoint(); + + // return early + if (!mv.isActiveLayerVisible() || Boolean.FALSE.equals(this.getValue("active")) || mouseDownButton != MouseEvent.BUTTON1) + return; + + // update which modifiers are pressed (shift, alt, ctrl) + updateKeyModifiers(e); + + DataSet ds = getLayerManager().getEditDataSet(); + if (ds == null) + return; + + final List selectedWays = new ArrayList<>(ds.getSelectedWays()); + Optional primitiveAtPoint = getPrimitiveAtPoint(e.getPoint()); + if (!primitiveAtPoint.isPresent()) + return; + + final OsmPrimitive nearestPrimitive = primitiveAtPoint.get(); + + if (nearestPrimitive instanceof Node) { + // Split way at node + Node n = (Node) nearestPrimitive; + + List applicableWays = getApplicableWays(n, selectedWays); + + if (applicableWays.isEmpty()) { + new Notification( + tr("The selected node is not in the middle of any non-closed way.")) + .setIcon(JOptionPane.WARNING_MESSAGE) + .show(); + return; + } + + if (applicableWays.size() > 1) { + createPopup(n, applicableWays).show(mv, mousePos.x, mousePos.y); + return; + } else { + final Way splitWay = applicableWays.get(0); + SplitWayAction.doSplitWayShowSegmentSelection(splitWay, Collections.singletonList(n), null); + if (updateUserFeedback(e)) { + MainApplication.getMap().mapView.repaint(); + } + } + } else if (nearestPrimitive instanceof Way) { + // TODO: Implement way splitting when a point on a way segment (not a node) is selected + // Insert node into way and split + + new Notification( + tr("Splitting in the middle of way segments is not yet implemented.")) + .setIcon(JOptionPane.WARNING_MESSAGE) + .show(); + return; + } + } + + @Override + public void mouseMoved(MouseEvent e) { + if (updateUserFeedback(e)) { + MainApplication.getMap().mapView.repaint(); + } + } + + private Optional getPrimitiveAtPoint(Point p) { + MapView mv = MainApplication.getMap().mapView; + return Optional.ofNullable(mv.getNearestNodeOrWay(p, mv.isSelectablePredicate, true)); + } + + /** + * Get a list of potential ways to be split for a given node + * @param n The node at which ways should be split + * @param preferredWays List of ways that should be prioritized over others. + * If one or more potential preferred ways are found, other ways are disregarded. + * @return List of potential ways to be split + */ + private List getApplicableWays(Node n, Collection preferredWays) { + final List parentWays = n.getParentWays(); + List applicableWays = parentWays.stream() + .filter(w -> w.isDrawable() && + !(w.isDisabled() && IGNORE_DISABLED_WAYS.get()) && + !w.isClosed() && + w.isInnerNode(n)) + .collect(Collectors.toList()); + + if (PREFER_SELECTED_WAYS.get() && preferredWays != null) { + List preferredApplicableWays = applicableWays.stream() + .filter(w -> preferredWays.contains(w)).collect(Collectors.toList()); + + if (!preferredApplicableWays.isEmpty()) { + applicableWays = preferredApplicableWays; + } + } + + return applicableWays; + } + + /** + * Create a new split way selection popup + * @param n Node at which ways should be split + * @param applicableWays Potential split ways to select from + * @return A new popup object + */ + private JPopupMenu createPopup(Node n, Collection applicableWays) { + JPopupMenu pm = new JPopupMenu("" + tr("Select way to split.
" + + "Hold CTRL for multiple selection.") + ""); + + Border titleUnderline = BorderFactory.createMatteBorder(1, 0, 0, 0, pm.getForeground()); + TitledBorder labelBorder = BorderFactory.createTitledBorder(titleUnderline, pm.getLabel(), + TitledBorder.CENTER, TitledBorder.ABOVE_TOP, pm.getFont(), pm.getForeground()); + pm.setBorder(BorderFactory.createCompoundBorder(pm.getBorder(), labelBorder)); + + for (final Way w : applicableWays) { + JMenuItem mi = new JMenuItem(new SplitWayActionConcrete(w, Collections.singletonList(n), null)); + + mi.setText("" + createLabelText(w) + ""); + + addHoverHighlightListener(mi, Arrays.asList(n, w)); + + mi.addFocusListener(new FocusAdapter() { + @Override + public void focusGained(FocusEvent e) { + if (highlight.highlightOnly(Arrays.asList(n, w))) { + MainApplication.getMap().mapView.repaint(); + } + } + + @Override + public void focusLost(FocusEvent e) { + if (removeHighlighting()) { + MainApplication.getMap().mapView.repaint(); + } + } + }); + + mi.addActionListener(actionEvent -> { + removeHighlighting(); + // Prevent popup menu from closing when ctrl is pressed while selecting a way to split + updateKeyModifiers(actionEvent); + if (platformMenuShortcutKeyMask) { + JMenuItem source = (JMenuItem) actionEvent.getSource(); + JPopupMenu popup = (JPopupMenu) source.getParent(); + popup.remove(source); + + // Close popup menu anyway when there are no more options left + if (popup.getSubElements().length > 0) { + popup.setVisible(true); + } + } + }); + + pm.add(mi); + } + + MenuScroller.setScrollerFor(pm); + return pm; + } + + /** + * Determine objects to highlight and update highlight + * @param e {@link MouseEvent} that triggered the update + * @return true if repaint is required + */ + private boolean updateUserFeedback(MouseEvent e) { + List toHighlight = new ArrayList<>(2); + + Optional pHovered = getPrimitiveAtPoint(e.getPoint()); + DataSet ds = getLayerManager().getEditDataSet(); + + if (pHovered.filter(p -> p instanceof Node).isPresent()) { + Node nHovered = (Node) pHovered.get(); + final List selectedWays = ds != null ? new ArrayList<>(ds.getSelectedWays()) : null; + List applicableWays = getApplicableWays(nHovered, selectedWays); + if (!applicableWays.isEmpty()) { + pHovered.ifPresent(toHighlight::add); + } + if (applicableWays.size() == 1) { + toHighlight.add(applicableWays.get(0)); + } + } + + return highlight.highlightOnly(toHighlight); + } + + /** + * Removes all existing highlights. + * @return true if a repaint is required + */ + private boolean removeHighlighting() { + boolean anyHighlighted = highlight.anyHighlighted(); + highlight.clear(); + return anyHighlighted; + } + + /** + * Add a mouse listener to the component {@code c} which highlights {@code prims} + * when the mouse pointer is hovering over the component + * @param c The component to add the hover mouse listener to + * @param prims The primitives to highlight when the component is hovered + */ + private void addHoverHighlightListener(Component c, Collection prims) { + c.addMouseListener(new MouseAdapter() { + @Override + public void mouseEntered(MouseEvent e) { + if (highlight.highlightOnly(prims)) { + MainApplication.getMap().mapView.repaint(); + } + } + + @Override + public void mouseExited(MouseEvent e) { + if (removeHighlighting()) { + MainApplication.getMap().mapView.repaint(); + } + } + }); + } + + /** + * Create the text for a {@link OsmPrimitive} label, including its keys + * @param primitive The {@link OsmPrimitive} to describe + * @return Text describing the {@link OsmPrimitive} + */ + private static String createLabelText(OsmPrimitive primitive) { + return createLabelText(primitive, true); + } + + /** + * Create the text for a {@link OsmPrimitive} label + * @param primitive The {@link OsmPrimitive} to describe + * @param includeKeys Include keys in description + * @return Text describing the {@link OsmPrimitive} + */ + private static String createLabelText(OsmPrimitive primitive, boolean includeKeys) { + final StringBuilder text = new StringBuilder(32); + String name = Utils.escapeReservedCharactersHTML(primitive.getDisplayName(DefaultNameFormatter.getInstance())); + if (primitive.isNewOrUndeleted() || primitive.isModified()) { + name = ""+ name + "*"; + } + text.append(name); + + if (!primitive.isNew()) { + text.append(" [id=").append(primitive.getId()).append(']'); + } + + if (primitive.getUser() != null) { + text.append(" [").append(tr("User:")).append(' ') + .append(Utils.escapeReservedCharactersHTML(primitive.getUser().getName())).append(']'); + } + + if (includeKeys) { + primitive.visitKeys((p, key, value) -> text.append("
").append(key).append('=').append(value)); + } + + return text.toString(); + } +} diff --git a/src/org/openstreetmap/josm/gui/MapFrame.java b/src/org/openstreetmap/josm/gui/MapFrame.java index 5337f1cb457..b935325ddcc 100644 --- a/src/org/openstreetmap/josm/gui/MapFrame.java +++ b/src/org/openstreetmap/josm/gui/MapFrame.java @@ -54,6 +54,7 @@ import org.openstreetmap.josm.actions.mapmode.ParallelWayAction; import org.openstreetmap.josm.actions.mapmode.SelectAction; import org.openstreetmap.josm.actions.mapmode.SelectLassoAction; +import org.openstreetmap.josm.actions.mapmode.SplitMode; import org.openstreetmap.josm.actions.mapmode.ZoomAction; import org.openstreetmap.josm.data.ViewportData; import org.openstreetmap.josm.data.preferences.AbstractProperty; @@ -180,6 +181,8 @@ public class MapFrame extends JPanel implements Destroyable, ActiveLayerChangeLi public final DeleteAction mapModeDelete; /** Select Lasso mode */ public final SelectLassoAction mapModeSelectLasso; + /** Split mode */ + public final SplitMode mapModeSplit; private final transient Map lastMapMode = new HashMap<>(); @@ -248,15 +251,17 @@ public MapFrame(ViewportData viewportData) { mapModeDraw = new DrawAction(); mapModeZoom = new ZoomAction(this); mapModeDelete = new DeleteAction(); + mapModeSplit = new SplitMode(); - addMapMode(new IconToggleButton(mapModeSelect)); + addMapMode(new IconToggleButton(mapModeSelect, false)); addMapMode(new IconToggleButton(mapModeSelectLasso, true)); - addMapMode(new IconToggleButton(mapModeDraw)); + addMapMode(new IconToggleButton(mapModeDraw, false)); addMapMode(new IconToggleButton(mapModeZoom, true)); addMapMode(new IconToggleButton(mapModeDelete, true)); addMapMode(new IconToggleButton(new ParallelWayAction(this), true)); addMapMode(new IconToggleButton(new ExtrudeAction(), true)); addMapMode(new IconToggleButton(new ImproveWayAccuracyAction(), false)); + addMapMode(new IconToggleButton(mapModeSplit, false)); toolBarActionsGroup.setSelected(allMapModeButtons.get(0).getModel(), true); toolBarActions.setFloatable(false); diff --git a/src/org/openstreetmap/josm/gui/util/HighlightHelper.java b/src/org/openstreetmap/josm/gui/util/HighlightHelper.java index 83b464e2b11..78f78f8ec81 100644 --- a/src/org/openstreetmap/josm/gui/util/HighlightHelper.java +++ b/src/org/openstreetmap/josm/gui/util/HighlightHelper.java @@ -107,6 +107,16 @@ private boolean setHighlight(OsmPrimitive p, boolean flag, Set seenRel return false; } + /** + * Returns an (unmodifiable) set of currently highlighted primitives + * @return Currently highlighted primitives + * + * @since xxx + */ + public Set getHighlighted() { + return Collections.unmodifiableSet(highlightedPrimitives); + } + /** * Clear highlighting of all remembered primitives */ @@ -117,6 +127,16 @@ public void clear() { highlightedPrimitives.clear(); } + /** + * Check whether there are any primitives highlighted + * @return true when there are highlighted primitives + * + * @since xxx + */ + public boolean anyHighlighted() { + return !highlightedPrimitives.isEmpty(); + } + /** * Slow method to import all currently highlighted primitives into this instance */