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