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