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