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