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.Main; 018import org.openstreetmap.josm.data.coor.LatLon; 019import org.openstreetmap.josm.data.osm.DataSet; 020import org.openstreetmap.josm.data.osm.Node; 021import org.openstreetmap.josm.data.osm.Tag; 022import org.openstreetmap.josm.data.preferences.sources.MapPaintPrefHelper; 023import org.openstreetmap.josm.data.preferences.sources.SourceEntry; 024import org.openstreetmap.josm.gui.mappaint.mapcss.MapCSSStyleSource; 025import org.openstreetmap.josm.gui.mappaint.styleelement.MapImage; 026import org.openstreetmap.josm.gui.mappaint.styleelement.NodeElement; 027import org.openstreetmap.josm.gui.mappaint.styleelement.StyleElement; 028import org.openstreetmap.josm.io.CachedFile; 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 loadStyleForFirstTime(source); 316 } 317 fireMapPaintSylesUpdated(); 318 } 319 320 private static void loadStyleForFirstTime(StyleSource source) { 321 final long startTime = System.currentTimeMillis(); 322 source.loadStyleSource(); 323 if (Config.getPref().getBoolean("mappaint.auto_reload_local_styles", true) && source.isLocal()) { 324 try { 325 Main.fileWatcher.registerSource(source); 326 } catch (IOException | IllegalStateException | IllegalArgumentException e) { 327 Logging.error(e); 328 } 329 } 330 if (Logging.isDebugEnabled() || !source.isValid()) { 331 final long elapsedTime = System.currentTimeMillis() - startTime; 332 String message = "Initializing map style " + source.url + " completed in " + Utils.getDurationString(elapsedTime); 333 if (!source.isValid()) { 334 Logging.warn(message + " (" + source.getErrors().size() + " errors, " + source.getWarnings().size() + " warnings)"); 335 } else { 336 Logging.debug(message); 337 } 338 } 339 } 340 341 private static StyleSource fromSourceEntry(SourceEntry entry) { 342 if (entry.url == null && entry instanceof MapCSSStyleSource) { 343 return (MapCSSStyleSource) entry; 344 } 345 Set<String> mimes = new HashSet<>(Arrays.asList(MapCSSStyleSource.MAPCSS_STYLE_MIME_TYPES.split(", "))); 346 try (CachedFile cf = new CachedFile(entry.url).setHttpAccept(Utils.join(", ", mimes))) { 347 String zipEntryPath = cf.findZipEntryPath("mapcss", "style"); 348 if (zipEntryPath != null) { 349 entry.isZip = true; 350 entry.zipEntryPath = zipEntryPath; 351 } 352 return new MapCSSStyleSource(entry); 353 } 354 } 355 356 /** 357 * Move position of entries in the current list of StyleSources 358 * @param sel The indices of styles to be moved. 359 * @param delta The number of lines it should move. positive int moves 360 * down and negative moves up. 361 */ 362 public static void moveStyles(int[] sel, int delta) { 363 if (!canMoveStyles(sel, delta)) 364 return; 365 int[] selSorted = Utils.copyArray(sel); 366 Arrays.sort(selSorted); 367 List<StyleSource> data = new ArrayList<>(styles.getStyleSources()); 368 for (int row: selSorted) { 369 StyleSource t1 = data.get(row); 370 StyleSource t2 = data.get(row + delta); 371 data.set(row, t2); 372 data.set(row + delta, t1); 373 } 374 styles.setStyleSources(data); 375 MapPaintPrefHelper.INSTANCE.put(data); 376 fireMapPaintSylesUpdated(); 377 } 378 379 /** 380 * Check if the styles can be moved 381 * @param sel The indexes of the selected styles 382 * @param i The number of places to move the styles 383 * @return <code>true</code> if that movement is possible 384 */ 385 public static boolean canMoveStyles(int[] sel, int i) { 386 if (sel.length == 0) 387 return false; 388 int[] selSorted = Utils.copyArray(sel); 389 Arrays.sort(selSorted); 390 391 if (i < 0) // Up 392 return selSorted[0] >= -i; 393 else if (i > 0) // Down 394 return selSorted[selSorted.length-1] <= styles.getStyleSources().size() - 1 - i; 395 else 396 return true; 397 } 398 399 /** 400 * Toggles the active state of several styles 401 * @param sel The style indexes 402 */ 403 public static void toggleStyleActive(int... sel) { 404 List<StyleSource> data = styles.getStyleSources(); 405 for (int p : sel) { 406 StyleSource s = data.get(p); 407 s.active = !s.active; 408 } 409 MapPaintPrefHelper.INSTANCE.put(data); 410 if (sel.length == 1) { 411 fireMapPaintStyleEntryUpdated(sel[0]); 412 } else { 413 fireMapPaintSylesUpdated(); 414 } 415 } 416 417 /** 418 * Add a new map paint style. 419 * @param entry map paint style 420 * @return loaded style source, or {@code null} 421 */ 422 public static StyleSource addStyle(SourceEntry entry) { 423 StyleSource source = fromSourceEntry(entry); 424 styles.add(source); 425 loadStyleForFirstTime(source); 426 refreshStyles(); 427 return source; 428 } 429 430 /** 431 * Remove a map paint style. 432 * @param entry map paint style 433 * @since 11493 434 */ 435 public static void removeStyle(SourceEntry entry) { 436 StyleSource source = fromSourceEntry(entry); 437 if (styles.remove(source)) { 438 refreshStyles(); 439 } 440 } 441 442 private static void refreshStyles() { 443 MapPaintPrefHelper.INSTANCE.put(styles.getStyleSources()); 444 fireMapPaintSylesUpdated(); 445 } 446 447 /*********************************** 448 * MapPaintSylesUpdateListener & related code 449 * (get informed when the list of MapPaint StyleSources changes) 450 */ 451 public interface MapPaintSylesUpdateListener { 452 /** 453 * Called on any style source changes that are not handled by {@link #mapPaintStyleEntryUpdated(int)} 454 */ 455 void mapPaintStylesUpdated(); 456 457 /** 458 * Called whenever a single style source entry was changed. 459 * @param index The index of the entry. 460 */ 461 void mapPaintStyleEntryUpdated(int index); 462 } 463 464 /** 465 * Add a listener that listens to global style changes. 466 * @param listener The listener 467 */ 468 public static void addMapPaintSylesUpdateListener(MapPaintSylesUpdateListener listener) { 469 listeners.addListener(listener); 470 } 471 472 /** 473 * Removes a listener that listens to global style changes. 474 * @param listener The listener 475 */ 476 public static void removeMapPaintSylesUpdateListener(MapPaintSylesUpdateListener listener) { 477 listeners.removeListener(listener); 478 } 479 480 /** 481 * Notifies all listeners that there was any update to the map paint styles 482 */ 483 public static void fireMapPaintSylesUpdated() { 484 listeners.fireEvent(MapPaintSylesUpdateListener::mapPaintStylesUpdated); 485 } 486 487 /** 488 * Notifies all listeners that there was an update to a specific map paint style 489 * @param index The style index 490 */ 491 public static void fireMapPaintStyleEntryUpdated(int index) { 492 listeners.fireEvent(l -> l.mapPaintStyleEntryUpdated(index)); 493 } 494}