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