001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.mappaint;
003
004import java.io.File;
005import java.io.IOException;
006import java.util.ArrayList;
007import java.util.Arrays;
008import java.util.Collection;
009import java.util.HashSet;
010import java.util.LinkedList;
011import java.util.List;
012import java.util.Set;
013
014import javax.swing.ImageIcon;
015import javax.swing.SwingUtilities;
016
017import org.openstreetmap.josm.Main;
018import org.openstreetmap.josm.data.coor.LatLon;
019import org.openstreetmap.josm.data.osm.DataSet;
020import org.openstreetmap.josm.data.osm.Node;
021import org.openstreetmap.josm.data.osm.Tag;
022import org.openstreetmap.josm.data.preferences.sources.MapPaintPrefHelper;
023import org.openstreetmap.josm.data.preferences.sources.SourceEntry;
024import org.openstreetmap.josm.gui.mappaint.mapcss.MapCSSStyleSource;
025import org.openstreetmap.josm.gui.mappaint.styleelement.MapImage;
026import org.openstreetmap.josm.gui.mappaint.styleelement.NodeElement;
027import org.openstreetmap.josm.gui.mappaint.styleelement.StyleElement;
028import org.openstreetmap.josm.io.CachedFile;
029import org.openstreetmap.josm.spi.preferences.Config;
030import org.openstreetmap.josm.tools.ImageProvider;
031import org.openstreetmap.josm.tools.ListenerList;
032import org.openstreetmap.josm.tools.Logging;
033import org.openstreetmap.josm.tools.Utils;
034
035/**
036 * This class manages the list of available map paint styles and gives access to
037 * the ElemStyles singleton.
038 *
039 * On change, {@link MapPaintSylesUpdateListener#mapPaintStylesUpdated()} is fired
040 * for all listeners.
041 */
042public final class MapPaintStyles {
043
044    private static final Collection<String> DEPRECATED_IMAGE_NAMES = Arrays.asList(
045            "presets/misc/deprecated.svg",
046            "misc/deprecated.png");
047
048    private static final ListenerList<MapPaintSylesUpdateListener> listeners = ListenerList.createUnchecked();
049
050    static {
051        listeners.addListener(new MapPaintSylesUpdateListener() {
052            @Override
053            public void mapPaintStylesUpdated() {
054                SwingUtilities.invokeLater(styles::clearCached);
055            }
056
057            @Override
058            public void mapPaintStyleEntryUpdated(int index) {
059                mapPaintStylesUpdated();
060            }
061        });
062    }
063
064    private static ElemStyles styles = new ElemStyles();
065
066    /**
067     * Returns the {@link ElemStyles} singleton instance.
068     *
069     * The returned object is read only, any manipulation happens via one of
070     * the other wrapper methods in this class. ({@link #readFromPreferences},
071     * {@link #moveStyles}, ...)
072     * @return the {@code ElemStyles} singleton instance
073     */
074    public static ElemStyles getStyles() {
075        return styles;
076    }
077
078    private MapPaintStyles() {
079        // Hide default constructor for utils classes
080    }
081
082    /**
083     * Value holder for a reference to a tag name. A style instruction
084     * <pre>
085     *    text: a_tag_name;
086     * </pre>
087     * results in a tag reference for the tag <code>a_tag_name</code> in the
088     * style cascade.
089     */
090    public static class TagKeyReference {
091        /**
092         * The tag name
093         */
094        public final String key;
095
096        /**
097         * Create a new {@link TagKeyReference}
098         * @param key The tag name
099         */
100        public TagKeyReference(String key) {
101            this.key = key;
102        }
103
104        @Override
105        public String toString() {
106            return "TagKeyReference{" + "key='" + key + "'}";
107        }
108    }
109
110    /**
111     * IconReference is used to remember the associated style source for each icon URL.
112     * This is necessary because image URLs can be paths relative
113     * to the source file and we have cascading of properties from different source files.
114     */
115    public static class IconReference {
116
117        /**
118         * The name of the icon
119         */
120        public final String iconName;
121        /**
122         * The style source this reference occurred in
123         */
124        public final StyleSource source;
125
126        /**
127         * Create a new {@link IconReference}
128         * @param iconName The icon name
129         * @param source The current style source
130         */
131        public IconReference(String iconName, StyleSource source) {
132            this.iconName = iconName;
133            this.source = source;
134        }
135
136        @Override
137        public String toString() {
138            return "IconReference{" + "iconName='" + iconName + "' source='" + source.getDisplayString() + "'}";
139        }
140
141        /**
142         * Determines whether this icon represents a deprecated icon
143         * @return whether this icon represents a deprecated icon
144         * @since 10927
145         */
146        public boolean isDeprecatedIcon() {
147            return DEPRECATED_IMAGE_NAMES.contains(iconName);
148        }
149    }
150
151    /**
152     * Image provider for icon. Note that this is a provider only. A @link{ImageProvider#get()} call may still fail!
153     *
154     * @param ref reference to the requested icon
155     * @param test if <code>true</code> than the icon is request is tested
156     * @return image provider for icon (can be <code>null</code> when <code>test</code> is <code>true</code>).
157     * @see #getIcon(IconReference, int,int)
158     * @since 8097
159     */
160    public static ImageProvider getIconProvider(IconReference ref, boolean test) {
161        final String namespace = ref.source.getPrefName();
162        ImageProvider i = new ImageProvider(ref.iconName)
163                .setDirs(getIconSourceDirs(ref.source))
164                .setId("mappaint."+namespace)
165                .setArchive(ref.source.zipIcons)
166                .setInArchiveDir(ref.source.getZipEntryDirName())
167                .setOptional(true);
168        if (test && i.get() == null) {
169            String msg = "Mappaint style \""+namespace+"\" ("+ref.source.getDisplayString()+") icon \"" + ref.iconName + "\" not found.";
170            ref.source.logWarning(msg);
171            Logging.warn(msg);
172            return null;
173        }
174        return i;
175    }
176
177    /**
178     * Return scaled icon.
179     *
180     * @param ref reference to the requested icon
181     * @param width icon width or -1 for autoscale
182     * @param height icon height or -1 for autoscale
183     * @return image icon or <code>null</code>.
184     * @see #getIconProvider(IconReference, boolean)
185     */
186    public static ImageIcon getIcon(IconReference ref, int width, int height) {
187        final String namespace = ref.source.getPrefName();
188        ImageIcon i = getIconProvider(ref, false).setSize(width, height).get();
189        if (i == null) {
190            Logging.warn("Mappaint style \""+namespace+"\" ("+ref.source.getDisplayString()+") icon \"" + ref.iconName + "\" not found.");
191            return null;
192        }
193        return i;
194    }
195
196    /**
197     * No icon with the given name was found, show a dummy icon instead
198     * @param source style source
199     * @return the icon misc/no_icon.png, in descending priority:
200     *   - relative to source file
201     *   - from user icon paths
202     *   - josm's default icon
203     *  can be null if the defaults are turned off by user
204     */
205    public static ImageIcon getNoIconIcon(StyleSource source) {
206        return new ImageProvider("presets/misc/no_icon")
207                .setDirs(getIconSourceDirs(source))
208                .setId("mappaint."+source.getPrefName())
209                .setArchive(source.zipIcons)
210                .setInArchiveDir(source.getZipEntryDirName())
211                .setOptional(true).get();
212    }
213
214    /**
215     * Returns the node icon that would be displayed for the given tag.
216     * @param tag The tag to look an icon for
217     * @return {@code null} if no icon found
218     */
219    public static ImageIcon getNodeIcon(Tag tag) {
220        return getNodeIcon(tag, true);
221    }
222
223    /**
224     * Returns the node icon that would be displayed for the given tag.
225     * @param tag The tag to look an icon for
226     * @param includeDeprecatedIcon if {@code true}, the special deprecated icon will be returned if applicable
227     * @return {@code null} if no icon found, or if the icon is deprecated and not wanted
228     */
229    public static ImageIcon getNodeIcon(Tag tag, boolean includeDeprecatedIcon) {
230        if (tag != null) {
231            DataSet ds = new DataSet();
232            Node virtualNode = new Node(LatLon.ZERO);
233            virtualNode.put(tag.getKey(), tag.getValue());
234            StyleElementList styleList;
235            MapCSSStyleSource.STYLE_SOURCE_LOCK.readLock().lock();
236            try {
237                // Add primitive to dataset to avoid DataIntegrityProblemException when evaluating selectors
238                ds.addPrimitive(virtualNode);
239                styleList = getStyles().generateStyles(virtualNode, 0.5, false).a;
240                ds.removePrimitive(virtualNode);
241            } finally {
242                MapCSSStyleSource.STYLE_SOURCE_LOCK.readLock().unlock();
243            }
244            if (styleList != null) {
245                for (StyleElement style : styleList) {
246                    if (style instanceof NodeElement) {
247                        MapImage mapImage = ((NodeElement) style).mapImage;
248                        if (mapImage != null) {
249                            if (includeDeprecatedIcon || mapImage.name == null || !DEPRECATED_IMAGE_NAMES.contains(mapImage.name)) {
250                                return new ImageIcon(mapImage.getImage(false));
251                            } else {
252                                return null; // Deprecated icon found but not wanted
253                            }
254                        }
255                    }
256                }
257            }
258        }
259        return null;
260    }
261
262    /**
263     * Gets the directories that should be searched for icons
264     * @param source The style source the icon is from
265     * @return A list of directory names
266     */
267    public static List<String> getIconSourceDirs(StyleSource source) {
268        List<String> dirs = new LinkedList<>();
269
270        File sourceDir = source.getLocalSourceDir();
271        if (sourceDir != null) {
272            dirs.add(sourceDir.getPath());
273        }
274
275        Collection<String> prefIconDirs = Config.getPref().getList("mappaint.icon.sources");
276        for (String fileset : prefIconDirs) {
277            String[] a;
278            if (fileset.indexOf('=') >= 0) {
279                a = fileset.split("=", 2);
280            } else {
281                a = new String[] {"", fileset};
282            }
283
284            /* non-prefixed path is generic path, always take it */
285            if (a[0].isEmpty() || source.getPrefName().equals(a[0])) {
286                dirs.add(a[1]);
287            }
288        }
289
290        if (Config.getPref().getBoolean("mappaint.icon.enable-defaults", true)) {
291            /* don't prefix icon path, as it should be generic */
292            dirs.add("resource://images/");
293        }
294
295        return dirs;
296    }
297
298    /**
299     * Reloads all styles from the preferences.
300     */
301    public static void readFromPreferences() {
302        styles.clear();
303
304        Collection<? extends SourceEntry> sourceEntries = MapPaintPrefHelper.INSTANCE.get();
305
306        for (SourceEntry entry : sourceEntries) {
307            try {
308                styles.add(fromSourceEntry(entry));
309            } catch (IllegalArgumentException e) {
310                Logging.error("Failed to load map paint style {0}", entry);
311                Logging.error(e);
312            }
313        }
314        for (StyleSource source : styles.getStyleSources()) {
315            if (source.active) {
316                loadStyleForFirstTime(source);
317            } else {
318                source.loadStyleSource(true);
319            }
320        }
321        fireMapPaintSylesUpdated();
322    }
323
324    private static void loadStyleForFirstTime(StyleSource source) {
325        final long startTime = System.currentTimeMillis();
326        source.loadStyleSource();
327        if (Config.getPref().getBoolean("mappaint.auto_reload_local_styles", true) && source.isLocal()) {
328            try {
329                Main.fileWatcher.registerSource(source);
330            } catch (IOException | IllegalStateException | IllegalArgumentException e) {
331                Logging.error(e);
332            }
333        }
334        if (Logging.isDebugEnabled() || !source.isValid()) {
335            final long elapsedTime = System.currentTimeMillis() - startTime;
336            String message = "Initializing map style " + source.url + " completed in " + Utils.getDurationString(elapsedTime);
337            if (!source.isValid()) {
338                Logging.warn(message + " (" + source.getErrors().size() + " errors, " + source.getWarnings().size() + " warnings)");
339            } else {
340                Logging.debug(message);
341            }
342        }
343    }
344
345    private static StyleSource fromSourceEntry(SourceEntry entry) {
346        if (entry.url == null && entry instanceof MapCSSStyleSource) {
347            return (MapCSSStyleSource) entry;
348        }
349        Set<String> mimes = new HashSet<>(Arrays.asList(MapCSSStyleSource.MAPCSS_STYLE_MIME_TYPES.split(", ")));
350        try (CachedFile cf = new CachedFile(entry.url).setHttpAccept(Utils.join(", ", mimes))) {
351            String zipEntryPath = cf.findZipEntryPath("mapcss", "style");
352            if (zipEntryPath != null) {
353                entry.isZip = true;
354                entry.zipEntryPath = zipEntryPath;
355            }
356            return new MapCSSStyleSource(entry);
357        }
358    }
359
360    /**
361     * Move position of entries in the current list of StyleSources
362     * @param sel The indices of styles to be moved.
363     * @param delta The number of lines it should move. positive int moves
364     *      down and negative moves up.
365     */
366    public static void moveStyles(int[] sel, int delta) {
367        if (!canMoveStyles(sel, delta))
368            return;
369        int[] selSorted = Utils.copyArray(sel);
370        Arrays.sort(selSorted);
371        List<StyleSource> data = new ArrayList<>(styles.getStyleSources());
372        for (int row: selSorted) {
373            StyleSource t1 = data.get(row);
374            StyleSource t2 = data.get(row + delta);
375            data.set(row, t2);
376            data.set(row + delta, t1);
377        }
378        styles.setStyleSources(data);
379        MapPaintPrefHelper.INSTANCE.put(data);
380        fireMapPaintSylesUpdated();
381    }
382
383    /**
384     * Check if the styles can be moved
385     * @param sel The indexes of the selected styles
386     * @param i The number of places to move the styles
387     * @return <code>true</code> if that movement is possible
388     */
389    public static boolean canMoveStyles(int[] sel, int i) {
390        if (sel.length == 0)
391            return false;
392        int[] selSorted = Utils.copyArray(sel);
393        Arrays.sort(selSorted);
394
395        if (i < 0) // Up
396            return selSorted[0] >= -i;
397        else if (i > 0) // Down
398            return selSorted[selSorted.length-1] <= styles.getStyleSources().size() - 1 - i;
399        else
400            return true;
401    }
402
403    /**
404     * Toggles the active state of several styles
405     * @param sel The style indexes
406     */
407    public static void toggleStyleActive(int... sel) {
408        List<StyleSource> data = styles.getStyleSources();
409        for (int p : sel) {
410            StyleSource s = data.get(p);
411            s.active = !s.active;
412            if (s.active && !s.isLoaded()) {
413                loadStyleForFirstTime(s);
414            }
415        }
416        MapPaintPrefHelper.INSTANCE.put(data);
417        if (sel.length == 1) {
418            fireMapPaintStyleEntryUpdated(sel[0]);
419        } else {
420            fireMapPaintSylesUpdated();
421        }
422    }
423
424    /**
425     * Add a new map paint style.
426     * @param entry map paint style
427     * @return loaded style source, or {@code null}
428     */
429    public static StyleSource addStyle(SourceEntry entry) {
430        StyleSource source = fromSourceEntry(entry);
431        styles.add(source);
432        loadStyleForFirstTime(source);
433        refreshStyles();
434        return source;
435    }
436
437    /**
438     * Remove a map paint style.
439     * @param entry map paint style
440     * @since 11493
441     */
442    public static void removeStyle(SourceEntry entry) {
443        StyleSource source = fromSourceEntry(entry);
444        if (styles.remove(source)) {
445            refreshStyles();
446        }
447    }
448
449    private static void refreshStyles() {
450        MapPaintPrefHelper.INSTANCE.put(styles.getStyleSources());
451        fireMapPaintSylesUpdated();
452    }
453
454    /***********************************
455     * MapPaintSylesUpdateListener &amp; related code
456     *  (get informed when the list of MapPaint StyleSources changes)
457     */
458    public interface MapPaintSylesUpdateListener {
459        /**
460         * Called on any style source changes that are not handled by {@link #mapPaintStyleEntryUpdated(int)}
461         */
462        void mapPaintStylesUpdated();
463
464        /**
465         * Called whenever a single style source entry was changed.
466         * @param index The index of the entry.
467         */
468        void mapPaintStyleEntryUpdated(int index);
469    }
470
471    /**
472     * Add a listener that listens to global style changes.
473     * @param listener The listener
474     */
475    public static void addMapPaintSylesUpdateListener(MapPaintSylesUpdateListener listener) {
476        listeners.addListener(listener);
477    }
478
479    /**
480     * Removes a listener that listens to global style changes.
481     * @param listener The listener
482     */
483    public static void removeMapPaintSylesUpdateListener(MapPaintSylesUpdateListener listener) {
484        listeners.removeListener(listener);
485    }
486
487    /**
488     * Notifies all listeners that there was any update to the map paint styles
489     */
490    public static void fireMapPaintSylesUpdated() {
491        listeners.fireEvent(MapPaintSylesUpdateListener::mapPaintStylesUpdated);
492    }
493
494    /**
495     * Notifies all listeners that there was an update to a specific map paint style
496     * @param index The style index
497     */
498    public static void fireMapPaintStyleEntryUpdated(int index) {
499        listeners.fireEvent(l -> l.mapPaintStyleEntryUpdated(index));
500    }
501}