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