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