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