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