From 1414061c2305619cbbf309afc27f2b310a5b64eb Mon Sep 17 00:00:00 2001 From: Taylor Smock Date: Tue, 30 Jun 2020 09:41:40 -0600 Subject: [PATCH] Create a dynamic menu that shows sources in the current view This fixes #88. Signed-off-by: Taylor Smock --- .../plugins/mapwithai/MapWithAIPlugin.java | 34 ++- .../actions/AddMapWithAILayerAction.java | 110 +++++++++ .../BoundingBoxMapWithAIDownloader.java | 14 +- .../data/mapwithai/MapWithAIInfo.java | 9 + .../plugins/mapwithai/gui/MapWithAIMenu.java | 229 ++++++++++++++++++ .../mapwithai/MapWithAIPluginTest.java | 10 +- 6 files changed, 380 insertions(+), 26 deletions(-) create mode 100644 src/main/java/org/openstreetmap/josm/plugins/mapwithai/actions/AddMapWithAILayerAction.java create mode 100644 src/main/java/org/openstreetmap/josm/plugins/mapwithai/gui/MapWithAIMenu.java diff --git a/src/main/java/org/openstreetmap/josm/plugins/mapwithai/MapWithAIPlugin.java b/src/main/java/org/openstreetmap/josm/plugins/mapwithai/MapWithAIPlugin.java index 8b1a683..5aaf536 100644 --- a/src/main/java/org/openstreetmap/josm/plugins/mapwithai/MapWithAIPlugin.java +++ b/src/main/java/org/openstreetmap/josm/plugins/mapwithai/MapWithAIPlugin.java @@ -1,9 +1,10 @@ // License: GPL. For details, see LICENSE file. package org.openstreetmap.josm.plugins.mapwithai; +import static org.openstreetmap.josm.gui.help.HelpUtil.ht; import static org.openstreetmap.josm.tools.I18n.tr; -import java.awt.Component; +import java.awt.event.KeyEvent; import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; import java.util.Arrays; @@ -12,10 +13,7 @@ import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Optional; -import java.util.stream.Collectors; -import javax.swing.Action; -import javax.swing.JMenu; import javax.swing.JMenuItem; import org.openstreetmap.josm.actions.JosmAction; @@ -46,6 +44,7 @@ import org.openstreetmap.josm.plugins.mapwithai.data.validation.tests.RoutingIsl import org.openstreetmap.josm.plugins.mapwithai.data.validation.tests.StreetAddressOrder; import org.openstreetmap.josm.plugins.mapwithai.data.validation.tests.StreetAddressTest; import org.openstreetmap.josm.plugins.mapwithai.data.validation.tests.StubEndsTest; +import org.openstreetmap.josm.plugins.mapwithai.gui.MapWithAIMenu; import org.openstreetmap.josm.plugins.mapwithai.gui.download.MapWithAIDownloadOptions; import org.openstreetmap.josm.plugins.mapwithai.gui.download.MapWithAIDownloadSourceType; import org.openstreetmap.josm.plugins.mapwithai.gui.preferences.MapWithAIPreferences; @@ -66,6 +65,8 @@ public final class MapWithAIPlugin extends Plugin implements Destroyable { private PreferencesAction preferenceAction; + private MapWithAIMenu mapwithaiMenu; + private static final Map, Boolean> MENU_ENTRIES = new LinkedHashMap<>(); static { MENU_ENTRIES.put(MapWithAIAction.class, false); @@ -81,13 +82,17 @@ public final class MapWithAIPlugin extends Plugin implements Destroyable { preferenceSetting = new MapWithAIPreferences(); - final JMenu dataMenu = MainApplication.getMenu().dataMenu; + // Add MapWithAI specific menu + mapwithaiMenu = new MapWithAIMenu(); + MainApplication.getMenu().addMenu(mapwithaiMenu, "mapwithai:menu", KeyEvent.VK_M, 9, ht("/Plugin/MapWithAI")); + for (final Entry, Boolean> entry : MENU_ENTRIES.entrySet()) { - if (Arrays.asList(dataMenu.getMenuComponents()).parallelStream().filter(JMenuItem.class::isInstance) + if (Arrays.asList(mapwithaiMenu.getMenuComponents()).parallelStream().filter(JMenuItem.class::isInstance) .map(JMenuItem.class::cast) .noneMatch(component -> entry.getKey().equals(component.getAction().getClass()))) { try { - MainMenu.add(dataMenu, entry.getKey().getDeclaredConstructor().newInstance(), entry.getValue()); + MainMenu.add(mapwithaiMenu, entry.getKey().getDeclaredConstructor().newInstance(), + entry.getValue()); } catch (InstantiationException | IllegalAccessException | IllegalArgumentException | InvocationTargetException | NoSuchMethodException | SecurityException e) { Logging.debug(e); @@ -98,7 +103,7 @@ public final class MapWithAIPlugin extends Plugin implements Destroyable { // Add the preferences last to data preferenceAction = PreferencesAction.forPreferenceTab(tr("MapWithAI Preferences"), tr("MapWithAI Preferences"), MapWithAIPreferences.class); - MainMenu.add(dataMenu, preferenceAction); + MainMenu.add(mapwithaiMenu, preferenceAction); VALIDATORS.forEach(clazz -> { if (!OsmValidator.getAllAvailableTestClasses().contains(clazz)) { @@ -166,18 +171,7 @@ public final class MapWithAIPlugin extends Plugin implements Destroyable { */ @Override public void destroy() { - final JMenu dataMenu = MainApplication.getMenu().dataMenu; - final Map actions = Arrays.asList(dataMenu.getMenuComponents()).stream() - .filter(JMenuItem.class::isInstance).map(JMenuItem.class::cast) - .collect(Collectors.toMap(JMenuItem::getAction, component -> component)); - - for (final Entry action : actions.entrySet()) { - if (MENU_ENTRIES.containsKey(action.getKey().getClass())) { - dataMenu.remove(action.getValue()); - } else if (action.getKey().equals(preferenceAction)) { - dataMenu.remove(action.getValue()); - } - } + MainApplication.getMenu().remove(this.mapwithaiMenu); MainApplication.getLayerManager().getLayersOfType(MapWithAILayer.class).stream() .forEach(layer -> MainApplication.getLayerManager().removeLayer(layer)); diff --git a/src/main/java/org/openstreetmap/josm/plugins/mapwithai/actions/AddMapWithAILayerAction.java b/src/main/java/org/openstreetmap/josm/plugins/mapwithai/actions/AddMapWithAILayerAction.java new file mode 100644 index 0000000..80c60aa --- /dev/null +++ b/src/main/java/org/openstreetmap/josm/plugins/mapwithai/actions/AddMapWithAILayerAction.java @@ -0,0 +1,110 @@ +// License: GPL. For details, see LICENSE file. +package org.openstreetmap.josm.plugins.mapwithai.actions; + +import static org.openstreetmap.josm.gui.help.HelpUtil.ht; + +import java.awt.event.ActionEvent; + +import org.openstreetmap.josm.actions.AdaptableAction; +import org.openstreetmap.josm.actions.AddImageryLayerAction; +import org.openstreetmap.josm.actions.JosmAction; +import org.openstreetmap.josm.data.osm.DataSet; +import org.openstreetmap.josm.data.osm.OsmData; +import org.openstreetmap.josm.gui.MainApplication; +import org.openstreetmap.josm.gui.preferences.ToolbarPreferences; +import org.openstreetmap.josm.gui.progress.NullProgressMonitor; +import org.openstreetmap.josm.gui.util.GuiHelper; +import org.openstreetmap.josm.io.OsmTransferException; +import org.openstreetmap.josm.plugins.mapwithai.backend.BoundingBoxMapWithAIDownloader; +import org.openstreetmap.josm.plugins.mapwithai.backend.MapWithAIDataUtils; +import org.openstreetmap.josm.plugins.mapwithai.backend.MapWithAILayer; +import org.openstreetmap.josm.plugins.mapwithai.data.mapwithai.MapWithAIInfo; +import org.openstreetmap.josm.tools.ImageProvider; +import org.openstreetmap.josm.tools.ImageProvider.ImageSizes; +import org.openstreetmap.josm.tools.ImageResource; +import org.openstreetmap.josm.tools.Logging; + +/** + * Action displayed in MapWithAI menu to add data to the MapWithAI layer. + * Largely copied from {@link AddImageryLayerAction}. + */ +public class AddMapWithAILayerAction extends JosmAction implements AdaptableAction { + private final transient MapWithAIInfo info; + + /** + * Constructs a new {@code AddMapWithAILayerAction} for the given + * {@code MapWithAIInfo}. If an http:// icon is specified, it is fetched + * asynchronously. + * + * @param info The source info + */ + public AddMapWithAILayerAction(MapWithAIInfo info) { + super(info.getName(), /* ICON */"imagery_menu", info.getToolTipText(), null, true, + ToolbarPreferences.IMAGERY_PREFIX + info.getToolbarName(), false); + setHelpId(ht("/Preferences/Imagery")); + this.info = info; + installAdapters(); + + // change toolbar icon from if specified + String icon = info.getIcon(); + if (icon != null) { + new ImageProvider(icon).setOptional(true).getResourceAsync(result -> { + if (result != null) { + GuiHelper.runInEDT(() -> result.attachImageIcon(this)); + } + }); + } else { + ImageResource resource = new ImageResource( + this.info.getSourceCategory().getIcon(ImageSizes.MENU).getImage()); + resource.attachImageIcon(this); + } + } + + @Override + public void actionPerformed(ActionEvent e) { + if (!isEnabled()) { + return; + } + + MapWithAILayer layer = MapWithAIDataUtils.getLayer(false); + final DataSet ds; + final OsmData boundsSource; + if (layer != null && !layer.getData().getDataSourceBounds().isEmpty()) { + ds = layer.getDataSet(); + boundsSource = ds; + } else if (MainApplication.getLayerManager().getActiveData() != null + && !MainApplication.getLayerManager().getActiveData().getDataSourceBounds().isEmpty()) { + boundsSource = MainApplication.getLayerManager().getActiveData(); + ds = MapWithAIDataUtils.getLayer(true).getDataSet(); + } else { + boundsSource = null; + ds = null; + } + if (boundsSource != null && ds != null) { + boundsSource.getDataSourceBounds().forEach(b -> MainApplication.worker.execute(() -> { + try { + ds.mergeFrom( + new BoundingBoxMapWithAIDownloader(b, info, false).parseOsm(NullProgressMonitor.INSTANCE)); + } catch (OsmTransferException error) { + Logging.error(error); + } + })); + } + + } + + @Override + protected boolean listenToSelectionChange() { + return false; + } + + @Override + protected void updateEnabledState() { + setEnabled(!info.isBlacklisted()); + } + + @Override + public String toString() { + return "AddMapWithAILayerAction [info=" + info + ']'; + } +} diff --git a/src/main/java/org/openstreetmap/josm/plugins/mapwithai/backend/BoundingBoxMapWithAIDownloader.java b/src/main/java/org/openstreetmap/josm/plugins/mapwithai/backend/BoundingBoxMapWithAIDownloader.java index 651145f..1959f47 100644 --- a/src/main/java/org/openstreetmap/josm/plugins/mapwithai/backend/BoundingBoxMapWithAIDownloader.java +++ b/src/main/java/org/openstreetmap/josm/plugins/mapwithai/backend/BoundingBoxMapWithAIDownloader.java @@ -34,7 +34,12 @@ import org.openstreetmap.josm.plugins.mapwithai.data.mapwithai.MapWithAIType; import org.openstreetmap.josm.tools.HttpClient; import org.openstreetmap.josm.tools.Logging; -class BoundingBoxMapWithAIDownloader extends BoundingBoxDownloader { +/** + * A bounding box downloader for MapWithAI + * + * @author Taylor Smock + */ +public class BoundingBoxMapWithAIDownloader extends BoundingBoxDownloader { private final String url; private final boolean crop; @@ -46,6 +51,13 @@ class BoundingBoxMapWithAIDownloader extends BoundingBoxDownloader { private static final int DEFAULT_TIMEOUT = 50_000; // 50 seconds + /** + * Create a new {@link BoundingBoxMapWithAIDownloader} object + * + * @param downloadArea The area to download + * @param info The info to use to get the url to download + * @param crop Whether or not to crop the download area + */ public BoundingBoxMapWithAIDownloader(Bounds downloadArea, MapWithAIInfo info, boolean crop) { super(downloadArea); this.info = info; diff --git a/src/main/java/org/openstreetmap/josm/plugins/mapwithai/data/mapwithai/MapWithAIInfo.java b/src/main/java/org/openstreetmap/josm/plugins/mapwithai/data/mapwithai/MapWithAIInfo.java index 3d5b41e..7ae5ac8 100644 --- a/src/main/java/org/openstreetmap/josm/plugins/mapwithai/data/mapwithai/MapWithAIInfo.java +++ b/src/main/java/org/openstreetmap/josm/plugins/mapwithai/data/mapwithai/MapWithAIInfo.java @@ -536,4 +536,13 @@ public class MapWithAIInfo extends } return Collections.unmodifiableList(this.conflationIgnoreCategory); } + + /** + * Get a string usable for toolbars + * + * @return Currently, the name of the source. + */ + public String getToolbarName() { + return this.getName(); + } } diff --git a/src/main/java/org/openstreetmap/josm/plugins/mapwithai/gui/MapWithAIMenu.java b/src/main/java/org/openstreetmap/josm/plugins/mapwithai/gui/MapWithAIMenu.java new file mode 100644 index 0000000..7abb348 --- /dev/null +++ b/src/main/java/org/openstreetmap/josm/plugins/mapwithai/gui/MapWithAIMenu.java @@ -0,0 +1,229 @@ +// License: GPL. For details, see LICENSE file. +package org.openstreetmap.josm.plugins.mapwithai.gui; + +import static org.openstreetmap.josm.tools.I18n.trc; + +import java.awt.Component; +import java.awt.GraphicsEnvironment; +import java.awt.MenuComponent; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.EnumMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Map.Entry; +import java.util.stream.Collectors; + +import javax.swing.Action; +import javax.swing.JMenu; +import javax.swing.JMenuItem; +import javax.swing.JPopupMenu; +import javax.swing.event.MenuEvent; +import javax.swing.event.MenuListener; + +import org.openstreetmap.josm.actions.JosmAction; +import org.openstreetmap.josm.data.coor.LatLon; +import org.openstreetmap.josm.data.imagery.Shape; +import org.openstreetmap.josm.data.sources.SourceInfo; +import org.openstreetmap.josm.gui.ImageryMenu; +import org.openstreetmap.josm.gui.MainApplication; +import org.openstreetmap.josm.gui.MapView; +import org.openstreetmap.josm.gui.MenuScroller; +import org.openstreetmap.josm.gui.preferences.imagery.ImageryPreference; +import org.openstreetmap.josm.plugins.mapwithai.actions.AddMapWithAILayerAction; +import org.openstreetmap.josm.plugins.mapwithai.data.mapwithai.MapWithAICategory; +import org.openstreetmap.josm.plugins.mapwithai.data.mapwithai.MapWithAIInfo; +import org.openstreetmap.josm.plugins.mapwithai.data.mapwithai.MapWithAILayerInfo; +import org.openstreetmap.josm.tools.ImageProvider.ImageSizes; +import org.openstreetmap.josm.tools.Logging; + +/** + * MapWithAI menu, holding entries for MapWithAI preferences and dynamic source + * entries depending on current mapview coordinates. + * + * Largely copied from {@link ImageryMenu}, but highly modified. + */ +public class MapWithAIMenu extends JMenu { + /** + * Compare MapWithAIInfo objects alphabetically by name. + * + * MapWithAIInfo objects are normally sorted by country code first (for the + * preferences). We don't want this in the MapWithAI menu. + */ + public static final Comparator> alphabeticSourceComparator = (ii1, ii2) -> ii1.getName() + .toLowerCase(Locale.ENGLISH).compareTo(ii2.getName().toLowerCase(Locale.ENGLISH)); + + /** + * Constructs a new {@code ImageryMenu}. + */ + public MapWithAIMenu() { + /* I18N: mnemonic: I */ + super(trc("menu", "MapWithAI")); + setupMenuScroller(); + // build dynamically + addMenuListener(new MenuListener() { + @Override + public void menuSelected(MenuEvent e) { + refreshImageryMenu(); + } + + @Override + public void menuDeselected(MenuEvent e) { + // Do nothing + } + + @Override + public void menuCanceled(MenuEvent e) { + // Do nothing + } + }); + } + + private void setupMenuScroller() { + if (!GraphicsEnvironment.isHeadless()) { + MenuScroller.setScrollerFor(this, 150, 2); + } + } + + /** + * For layers containing complex shapes, check that center is in one of its + * shapes (fix #7910) + * + * @param info layer info + * @param pos center + * @return {@code true} if center is in one of info shapes + */ + private static boolean isPosInOneShapeIfAny(SourceInfo info, LatLon pos) { + List shapes = info.getBounds().getShapes(); + return shapes == null || shapes.isEmpty() || shapes.stream().anyMatch(s -> s.contains(pos)); + } + + /** + * Refresh imagery menu. + * + * Outside this class only called in {@link ImageryPreference#initialize()}. (In + * order to have actions ready for the toolbar, see #8446.) + */ + public void refreshImageryMenu() { + removeDynamicItems(); + + addDynamicSeparator(); + + // for each configured ImageryInfo, add a menu entry. + final List savedLayers = new ArrayList<>(MapWithAILayerInfo.getInstance().getLayers()); + savedLayers.sort(alphabeticSourceComparator); + for (final MapWithAIInfo u : savedLayers) { + addDynamic(trackJosmAction(new AddMapWithAILayerAction(u)), null); + } + + // list all imagery entries where the current map location is within the imagery + // bounds + if (MainApplication.isDisplayingMapView()) { + MapView mv = MainApplication.getMap().mapView; + LatLon pos = mv.getProjection().eastNorth2latlon(mv.getCenter()); + final List alreadyInUse = MapWithAILayerInfo.getInstance().getLayers(); + final List inViewLayers = MapWithAILayerInfo.getInstance().getDefaultLayers().stream() + .filter(i -> i.getBounds() != null && i.getBounds().contains(pos) && !alreadyInUse.contains(i) + && isPosInOneShapeIfAny(i, pos)) + .sorted(alphabeticSourceComparator).collect(Collectors.toList()); + if (!inViewLayers.isEmpty()) { + if (inViewLayers.stream().anyMatch(i -> i.getCategory() == i.getCategory().getDefault())) { + addDynamicSeparator(); + } + for (MapWithAIInfo i : inViewLayers) { + addDynamic(trackJosmAction(new AddMapWithAILayerAction(i)), i.getCategory()); + } + } + if (!dynamicNonPhotoItems.isEmpty()) { + addDynamicSeparator(); + for (Entry> e : dynamicNonPhotoItems.entrySet()) { + MapWithAICategory cat = e.getKey(); + List list = e.getValue(); + if (list.size() > 1) { + JMenuItem categoryMenu = new JMenu(cat.getDescription()); + categoryMenu.setIcon(cat.getIcon(ImageSizes.MENU)); + for (JMenuItem it : list) { + categoryMenu.add(it); + } + dynamicNonPhotoMenus.add(add(categoryMenu)); + } else if (!list.isEmpty()) { + dynamicNonPhotoMenus.add(add(list.get(0))); + } + } + } + } + } + + /** + * List to store temporary "photo" menu items. They will be deleted (and + * possibly recreated) when refreshImageryMenu() is called. + */ + private final List dynamicItems = new ArrayList<>(20); + /** + * Map to store temporary "not photo" menu items. They will be deleted (and + * possibly recreated) when refreshImageryMenu() is called. + */ + private final Map> dynamicNonPhotoItems = new EnumMap<>(MapWithAICategory.class); + /** + * List to store temporary "not photo" submenus. They will be deleted (and + * possibly recreated) when refreshImageryMenu() is called. + */ + private final List dynamicNonPhotoMenus = new ArrayList<>(20); + private final List dynJosmActions = new ArrayList<>(20); + + /** + * Remove all the items in dynamic items collection + * + * @since 5803 + */ + private void removeDynamicItems() { + dynJosmActions.forEach(JosmAction::destroy); + dynJosmActions.clear(); + dynamicItems.forEach(this::removeDynamicItem); + dynamicItems.clear(); + dynamicNonPhotoMenus.forEach(this::removeDynamicItem); + dynamicItems.clear(); + dynamicNonPhotoItems.clear(); + } + + private void removeDynamicItem(Object item) { + if (item instanceof JMenuItem) { + remove((JMenuItem) item); + } else if (item instanceof MenuComponent) { + remove((MenuComponent) item); + } else if (item instanceof Component) { + remove((Component) item); + } else { + Logging.error("Unknown imagery menu item type: {0}", item); + } + } + + private void addDynamicSeparator() { + JPopupMenu.Separator s = new JPopupMenu.Separator(); + dynamicItems.add(s); + add(s); + } + + private void addDynamic(Action a, MapWithAICategory category) { + JMenuItem item = createActionComponent(a); + item.setAction(a); + doAddDynamic(item, category); + } + + private void doAddDynamic(JMenuItem item, MapWithAICategory category) { + if (category == null || category == MapWithAICategory.FEATURED) { + dynamicItems.add(this.add(item)); + } else { + dynamicNonPhotoItems.computeIfAbsent(category, x -> new ArrayList<>()).add(item); + } + } + + private Action trackJosmAction(Action action) { + if (action instanceof JosmAction) { + dynJosmActions.add((JosmAction) action); + } + return action; + } + +} diff --git a/test/unit/org/openstreetmap/josm/plugins/mapwithai/MapWithAIPluginTest.java b/test/unit/org/openstreetmap/josm/plugins/mapwithai/MapWithAIPluginTest.java index 86bf2a4..1396e88 100644 --- a/test/unit/org/openstreetmap/josm/plugins/mapwithai/MapWithAIPluginTest.java +++ b/test/unit/org/openstreetmap/josm/plugins/mapwithai/MapWithAIPluginTest.java @@ -88,10 +88,11 @@ public class MapWithAIPluginTest { menuEntries.setAccessible(true); // + 1 comes from the preferences panel final int addedMenuItems = ((Map) menuEntries.get(plugin)).size() + 1; - final JMenu dataMenu = MainApplication.getMenu().dataMenu; - final int dataMenuSize = dataMenu.getMenuComponentCount(); plugin = new MapWithAIPlugin(info); - assertEquals(dataMenuSize + addedMenuItems, dataMenu.getMenuComponentCount(), "Menu items were not added"); + // Currently adding the menu at the 9th index + final JMenu dataMenu = MainApplication.getMenu().getMenu(9); + final int dataMenuSize = dataMenu.getMenuComponentCount(); + assertEquals(addedMenuItems, dataMenu.getMenuComponentCount(), "Menu items were not added"); assertEquals(1, MapPaintStyles.getStyles().getStyleSources().parallelStream() .filter(source -> source.url != null && source.name.contains("MapWithAI")).count(), @@ -114,8 +115,7 @@ public class MapWithAIPluginTest { for (int i = 0; i < 3; i++) { plugin = new MapWithAIPlugin(info); - assertEquals(dataMenuSize + addedMenuItems, dataMenu.getMenuComponentCount(), - "The menu items were added multiple times"); + assertEquals(addedMenuItems, dataMenu.getMenuComponentCount(), "The menu items were added multiple times"); assertEquals(1, MapPaintStyles.getStyles().getStyleSources().parallelStream() .filter(source -> source.url != null && source.name.contains("MapWithAI")).count(),