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