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