001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.gui.dialogs.layer; 003 004import static org.openstreetmap.josm.tools.I18n.tr; 005 006import java.awt.BorderLayout; 007import java.awt.Color; 008import java.awt.Component; 009import java.awt.Dimension; 010import java.awt.GridBagLayout; 011import java.awt.event.ActionEvent; 012import java.awt.event.MouseAdapter; 013import java.awt.event.MouseEvent; 014import java.awt.event.MouseWheelEvent; 015import java.util.ArrayList; 016import java.util.Collection; 017import java.util.HashMap; 018import java.util.List; 019import java.util.function.Supplier; 020import java.util.stream.Collectors; 021 022import javax.swing.AbstractAction; 023import javax.swing.BorderFactory; 024import javax.swing.Icon; 025import javax.swing.ImageIcon; 026import javax.swing.JCheckBox; 027import javax.swing.JComponent; 028import javax.swing.JLabel; 029import javax.swing.JMenuItem; 030import javax.swing.JPanel; 031import javax.swing.JPopupMenu; 032import javax.swing.JSlider; 033import javax.swing.UIManager; 034import javax.swing.border.Border; 035 036import org.openstreetmap.josm.Main; 037import org.openstreetmap.josm.gui.SideButton; 038import org.openstreetmap.josm.gui.dialogs.IEnabledStateUpdating; 039import org.openstreetmap.josm.gui.dialogs.LayerListDialog.LayerListModel; 040import org.openstreetmap.josm.gui.layer.GpxLayer; 041import org.openstreetmap.josm.gui.layer.ImageryLayer; 042import org.openstreetmap.josm.gui.layer.Layer; 043import org.openstreetmap.josm.gui.layer.Layer.LayerAction; 044import org.openstreetmap.josm.gui.layer.imagery.ImageryFilterSettings; 045import org.openstreetmap.josm.tools.GBC; 046import org.openstreetmap.josm.tools.ImageProvider; 047import org.openstreetmap.josm.tools.Utils; 048 049/** 050 * This is a menu that includes all settings for the layer visibility. It combines gamma/opacity sliders and the visible-checkbox. 051 * 052 * @author Michael Zangl 053 */ 054public final class LayerVisibilityAction extends AbstractAction implements IEnabledStateUpdating, LayerAction { 055 private static final String DIALOGS_LAYERLIST = "dialogs/layerlist"; 056 private static final int SLIDER_STEPS = 100; 057 /** 058 * Steps the value is changed by a mouse wheel change (one full click) 059 */ 060 private static final int SLIDER_WHEEL_INCREMENT = 5; 061 private static final double MAX_SHARPNESS_FACTOR = 2; 062 private static final double MAX_COLORFUL_FACTOR = 2; 063 private final LayerListModel model; 064 private final JPopupMenu popup; 065 private SideButton sideButton; 066 /** 067 * The real content, just to add a border 068 */ 069 private final JPanel content = new JPanel(); 070 final OpacitySlider opacitySlider = new OpacitySlider(); 071 private final ArrayList<LayerVisibilityMenuEntry> sliders = new ArrayList<>(); 072 073 /** 074 * Creates a new {@link LayerVisibilityAction} 075 * @param model The list to get the selection from. 076 */ 077 public LayerVisibilityAction(LayerListModel model) { 078 this.model = model; 079 popup = new JPopupMenu(); 080 // prevent popup close on mouse wheel move 081 popup.addMouseWheelListener(MouseWheelEvent::consume); 082 083 popup.add(content); 084 content.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); 085 content.setLayout(new GridBagLayout()); 086 087 new ImageProvider(DIALOGS_LAYERLIST, "visibility").getResource().attachImageIcon(this, true); 088 putValue(SHORT_DESCRIPTION, tr("Change visibility of the selected layer.")); 089 090 addContentEntry(new VisibilityCheckbox()); 091 092 addContentEntry(opacitySlider); 093 addContentEntry(new ColorfulnessSlider()); 094 addContentEntry(new GammaFilterSlider()); 095 addContentEntry(new SharpnessSlider()); 096 addContentEntry(new ColorSelector(model::getSelectedLayers)); 097 } 098 099 private void addContentEntry(LayerVisibilityMenuEntry slider) { 100 content.add(slider.getPanel(), GBC.eop().fill(GBC.HORIZONTAL)); 101 sliders.add(slider); 102 } 103 104 void setVisibleFlag(boolean visible) { 105 for (Layer l : model.getSelectedLayers()) { 106 l.setVisible(visible); 107 } 108 updateValues(); 109 } 110 111 @Override 112 public void actionPerformed(ActionEvent e) { 113 updateValues(); 114 if (e.getSource() == sideButton) { 115 popup.show(sideButton, 0, sideButton.getHeight()); 116 } else { 117 // Action can be trigger either by opacity button or by popup menu (in case toggle buttons are hidden). 118 // In that case, show it in the middle of screen (because opacityButton is not visible) 119 popup.show(Main.parent, Main.parent.getWidth() / 2, (Main.parent.getHeight() - popup.getHeight()) / 2); 120 } 121 } 122 123 void updateValues() { 124 List<Layer> layers = model.getSelectedLayers(); 125 126 boolean allVisible = true; 127 boolean allHidden = true; 128 for (Layer l : layers) { 129 allVisible &= l.isVisible(); 130 allHidden &= !l.isVisible(); 131 } 132 133 for (LayerVisibilityMenuEntry slider : sliders) { 134 slider.updateLayers(layers, allVisible, allHidden); 135 } 136 } 137 138 @Override 139 public boolean supportLayers(List<Layer> layers) { 140 return !layers.isEmpty(); 141 } 142 143 @Override 144 public Component createMenuComponent() { 145 return new JMenuItem(this); 146 } 147 148 @Override 149 public void updateEnabledState() { 150 setEnabled(!model.getSelectedLayers().isEmpty()); 151 } 152 153 /** 154 * Sets the corresponding side button. 155 * @param sideButton the corresponding side button 156 */ 157 public void setCorrespondingSideButton(SideButton sideButton) { 158 this.sideButton = sideButton; 159 } 160 161 /** 162 * An entry in the visibility settings dropdown. 163 * @author Michael Zangl 164 */ 165 private interface LayerVisibilityMenuEntry { 166 167 /** 168 * Update the displayed value depending on the current layers 169 * @param layers The layers 170 * @param allVisible <code>true</code> if all layers are visible 171 * @param allHidden <code>true</code> if all layers are hidden 172 */ 173 void updateLayers(List<Layer> layers, boolean allVisible, boolean allHidden); 174 175 /** 176 * Get the panel that should be added to the menu 177 * @return The panel 178 */ 179 JComponent getPanel(); 180 } 181 182 private class VisibilityCheckbox extends JCheckBox implements LayerVisibilityMenuEntry { 183 184 VisibilityCheckbox() { 185 super(tr("Show layer")); 186 187 // Align all texts 188 Icon icon = UIManager.getIcon("CheckBox.icon"); 189 int iconWidth = icon == null ? 20 : icon.getIconWidth(); 190 setBorder(BorderFactory.createEmptyBorder(0, Math.max(24 + 5 - iconWidth, 0), 0, 0)); 191 addChangeListener(e -> setVisibleFlag(isSelected())); 192 } 193 194 @Override 195 public void updateLayers(List<Layer> layers, boolean allVisible, boolean allHidden) { 196 setEnabled(!layers.isEmpty()); 197 // TODO: Indicate tristate. 198 setSelected(allVisible && !allHidden); 199 } 200 201 @Override 202 public JComponent getPanel() { 203 return this; 204 } 205 } 206 207 /** 208 * This is a slider for a filter value. 209 * @author Michael Zangl 210 * 211 * @param <T> The layer type. 212 */ 213 private abstract class AbstractFilterSlider<T extends Layer> extends JPanel implements LayerVisibilityMenuEntry { 214 private final double minValue; 215 private final double maxValue; 216 private final Class<T> layerClassFilter; 217 218 protected final JSlider slider = new JSlider(JSlider.HORIZONTAL); 219 220 /** 221 * Create a new filter slider. 222 * @param minValue The minimum value to map to the left side. 223 * @param maxValue The maximum value to map to the right side. 224 * @param layerClassFilter The type of layer influenced by this filter. 225 */ 226 AbstractFilterSlider(double minValue, double maxValue, Class<T> layerClassFilter) { 227 super(new GridBagLayout()); 228 this.minValue = minValue; 229 this.maxValue = maxValue; 230 this.layerClassFilter = layerClassFilter; 231 232 add(new JLabel(getIcon()), GBC.std().span(1, 2).insets(0, 0, 5, 0)); 233 add(new JLabel(getLabel()), GBC.eol().insets(5, 0, 5, 0)); 234 add(slider, GBC.eol()); 235 addMouseWheelListener(this::mouseWheelMoved); 236 237 slider.setMaximum(SLIDER_STEPS); 238 int tick = convertFromRealValue(1); 239 slider.setMinorTickSpacing(tick); 240 slider.setMajorTickSpacing(tick); 241 slider.setPaintTicks(true); 242 243 slider.addChangeListener(e -> onStateChanged()); 244 } 245 246 /** 247 * Called whenever the state of the slider was changed. 248 * @see JSlider#getValueIsAdjusting() 249 * @see #getRealValue() 250 */ 251 protected void onStateChanged() { 252 Collection<T> layers = filterLayers(model.getSelectedLayers()); 253 for (T layer : layers) { 254 applyValueToLayer(layer); 255 } 256 } 257 258 protected void mouseWheelMoved(MouseWheelEvent e) { 259 e.consume(); 260 if (!isEnabled()) { 261 // ignore mouse wheel in disabled state. 262 return; 263 } 264 double rotation = -1 * e.getPreciseWheelRotation(); 265 double destinationValue = slider.getValue() + rotation * SLIDER_WHEEL_INCREMENT; 266 if (rotation < 0) { 267 destinationValue = Math.floor(destinationValue); 268 } else { 269 destinationValue = Math.ceil(destinationValue); 270 } 271 slider.setValue(Utils.clamp((int) destinationValue, slider.getMinimum(), slider.getMaximum())); 272 } 273 274 abstract void applyValueToLayer(T layer); 275 276 protected double getRealValue() { 277 return convertToRealValue(slider.getValue()); 278 } 279 280 protected double convertToRealValue(int value) { 281 double s = (double) value / SLIDER_STEPS; 282 return s * maxValue + (1-s) * minValue; 283 } 284 285 protected void setRealValue(double value) { 286 slider.setValue(convertFromRealValue(value)); 287 } 288 289 protected int convertFromRealValue(double value) { 290 int i = (int) ((value - minValue) / (maxValue - minValue) * SLIDER_STEPS + .5); 291 return Utils.clamp(i, slider.getMinimum(), slider.getMaximum()); 292 } 293 294 public abstract ImageIcon getIcon(); 295 296 public abstract String getLabel(); 297 298 @Override 299 public void updateLayers(List<Layer> layers, boolean allVisible, boolean allHidden) { 300 Collection<? extends Layer> usedLayers = filterLayers(layers); 301 setVisible(!usedLayers.isEmpty()); 302 if (!usedLayers.stream().anyMatch(Layer::isVisible)) { 303 slider.setEnabled(false); 304 } else { 305 slider.setEnabled(true); 306 updateSliderWhileEnabled(usedLayers, allHidden); 307 } 308 } 309 310 protected Collection<T> filterLayers(List<Layer> layers) { 311 return Utils.filteredCollection(layers, layerClassFilter); 312 } 313 314 protected abstract void updateSliderWhileEnabled(Collection<? extends Layer> usedLayers, boolean allHidden); 315 316 @Override 317 public JComponent getPanel() { 318 return this; 319 } 320 } 321 322 /** 323 * This slider allows you to change the opacity of a layer. 324 * 325 * @author Michael Zangl 326 * @see Layer#setOpacity(double) 327 */ 328 class OpacitySlider extends AbstractFilterSlider<Layer> { 329 /** 330 * Creaate a new {@link OpacitySlider}. 331 */ 332 OpacitySlider() { 333 super(0, 1, Layer.class); 334 slider.setToolTipText(tr("Adjust opacity of the layer.")); 335 } 336 337 @Override 338 protected void onStateChanged() { 339 if (getRealValue() <= 0.001 && !slider.getValueIsAdjusting()) { 340 setVisibleFlag(false); 341 } else { 342 super.onStateChanged(); 343 } 344 } 345 346 @Override 347 protected void mouseWheelMoved(MouseWheelEvent e) { 348 if (!isEnabled() && !filterLayers(model.getSelectedLayers()).isEmpty() && e.getPreciseWheelRotation() < 0) { 349 // make layer visible and set the value. 350 // this allows users to use the mouse wheel to make the layer visible if it was hidden previously. 351 e.consume(); 352 setVisibleFlag(true); 353 } else { 354 super.mouseWheelMoved(e); 355 } 356 } 357 358 @Override 359 protected void applyValueToLayer(Layer layer) { 360 layer.setOpacity(getRealValue()); 361 } 362 363 @Override 364 protected void updateSliderWhileEnabled(Collection<? extends Layer> usedLayers, boolean allHidden) { 365 double opacity = 0; 366 for (Layer l : usedLayers) { 367 opacity += l.getOpacity(); 368 } 369 opacity /= usedLayers.size(); 370 if (opacity == 0) { 371 opacity = 1; 372 setVisibleFlag(true); 373 } 374 setRealValue(opacity); 375 } 376 377 @Override 378 public String getLabel() { 379 return tr("Opacity"); 380 } 381 382 @Override 383 public ImageIcon getIcon() { 384 return ImageProvider.get(DIALOGS_LAYERLIST, "transparency"); 385 } 386 387 @Override 388 public String toString() { 389 return "OpacitySlider [getRealValue()=" + getRealValue() + ']'; 390 } 391 } 392 393 /** 394 * This slider allows you to change the gamma value of a layer. 395 * 396 * @author Michael Zangl 397 * @see ImageryFilterSettings#setGamma(double) 398 */ 399 private class GammaFilterSlider extends AbstractFilterSlider<ImageryLayer> { 400 401 /** 402 * Create a new {@link GammaFilterSlider} 403 */ 404 GammaFilterSlider() { 405 super(-1, 1, ImageryLayer.class); 406 slider.setToolTipText(tr("Adjust gamma value of the layer.")); 407 } 408 409 @Override 410 protected void updateSliderWhileEnabled(Collection<? extends Layer> usedLayers, boolean allHidden) { 411 double gamma = ((ImageryLayer) usedLayers.iterator().next()).getFilterSettings().getGamma(); 412 setRealValue(mapGammaToInterval(gamma)); 413 } 414 415 @Override 416 protected void applyValueToLayer(ImageryLayer layer) { 417 layer.getFilterSettings().setGamma(mapIntervalToGamma(getRealValue())); 418 } 419 420 @Override 421 public ImageIcon getIcon() { 422 return ImageProvider.get(DIALOGS_LAYERLIST, "gamma"); 423 } 424 425 @Override 426 public String getLabel() { 427 return tr("Gamma"); 428 } 429 430 /** 431 * Maps a number x from the range (-1,1) to a gamma value. 432 * Gamma value is in the range (0, infinity). 433 * Gamma values of 3 and 1/3 have opposite effects, so the mapping 434 * should be symmetric in that sense. 435 * @param x the slider value in the range (-1,1) 436 * @return the gamma value 437 */ 438 private double mapIntervalToGamma(double x) { 439 // properties of the mapping: 440 // g(-1) = 0 441 // g(0) = 1 442 // g(1) = infinity 443 // g(-x) = 1 / g(x) 444 return (1 + x) / (1 - x); 445 } 446 447 private double mapGammaToInterval(double gamma) { 448 return (gamma - 1) / (gamma + 1); 449 } 450 } 451 452 /** 453 * This slider allows you to change the sharpness of a layer. 454 * 455 * @author Michael Zangl 456 * @see ImageryFilterSettings#setSharpenLevel(double) 457 */ 458 private class SharpnessSlider extends AbstractFilterSlider<ImageryLayer> { 459 460 /** 461 * Creates a new {@link SharpnessSlider} 462 */ 463 SharpnessSlider() { 464 super(0, MAX_SHARPNESS_FACTOR, ImageryLayer.class); 465 slider.setToolTipText(tr("Adjust sharpness/blur value of the layer.")); 466 } 467 468 @Override 469 protected void updateSliderWhileEnabled(Collection<? extends Layer> usedLayers, boolean allHidden) { 470 setRealValue(((ImageryLayer) usedLayers.iterator().next()).getFilterSettings().getSharpenLevel()); 471 } 472 473 @Override 474 protected void applyValueToLayer(ImageryLayer layer) { 475 layer.getFilterSettings().setSharpenLevel(getRealValue()); 476 } 477 478 @Override 479 public ImageIcon getIcon() { 480 return ImageProvider.get(DIALOGS_LAYERLIST, "sharpness"); 481 } 482 483 @Override 484 public String getLabel() { 485 return tr("Sharpness"); 486 } 487 } 488 489 /** 490 * This slider allows you to change the colorfulness of a layer. 491 * 492 * @author Michael Zangl 493 * @see ImageryFilterSettings#setColorfulness(double) 494 */ 495 private class ColorfulnessSlider extends AbstractFilterSlider<ImageryLayer> { 496 497 /** 498 * Create a new {@link ColorfulnessSlider} 499 */ 500 ColorfulnessSlider() { 501 super(0, MAX_COLORFUL_FACTOR, ImageryLayer.class); 502 slider.setToolTipText(tr("Adjust colorfulness of the layer.")); 503 } 504 505 @Override 506 protected void updateSliderWhileEnabled(Collection<? extends Layer> usedLayers, boolean allHidden) { 507 setRealValue(((ImageryLayer) usedLayers.iterator().next()).getFilterSettings().getColorfulness()); 508 } 509 510 @Override 511 protected void applyValueToLayer(ImageryLayer layer) { 512 layer.getFilterSettings().setColorfulness(getRealValue()); 513 } 514 515 @Override 516 public ImageIcon getIcon() { 517 return ImageProvider.get(DIALOGS_LAYERLIST, "colorfulness"); 518 } 519 520 @Override 521 public String getLabel() { 522 return tr("Colorfulness"); 523 } 524 } 525 526 /** 527 * Allows to select the color for the GPX layer 528 * @author Michael Zangl 529 */ 530 private static class ColorSelector extends JPanel implements LayerVisibilityMenuEntry { 531 532 private static final Border NORMAL_BORDER = BorderFactory.createEmptyBorder(2, 2, 2, 2); 533 private static final Border SELECTED_BORDER = BorderFactory.createLineBorder(Color.BLACK, 2); 534 535 // TODO: Nicer color palette 536 private static final Color[] COLORS = new Color[] { 537 Color.RED, 538 Color.ORANGE, 539 Color.YELLOW, 540 Color.GREEN, 541 Color.BLUE, 542 Color.CYAN, 543 Color.GRAY, 544 }; 545 private final Supplier<List<Layer>> layerSupplier; 546 private final HashMap<Color, JPanel> panels = new HashMap<>(); 547 548 ColorSelector(Supplier<List<Layer>> layerSupplier) { 549 super(new GridBagLayout()); 550 this.layerSupplier = layerSupplier; 551 add(new JLabel(tr("Color")), GBC.eol().insets(24 + 10, 0, 0, 0)); 552 for (Color color : COLORS) { 553 addPanelForColor(color); 554 } 555 } 556 557 private void addPanelForColor(Color color) { 558 JPanel innerPanel = new JPanel(); 559 innerPanel.setBackground(color); 560 561 JPanel colorPanel = new JPanel(new BorderLayout()); 562 colorPanel.setBorder(NORMAL_BORDER); 563 colorPanel.add(innerPanel); 564 colorPanel.setMinimumSize(new Dimension(20, 20)); 565 colorPanel.addMouseListener(new MouseAdapter() { 566 @Override 567 public void mouseClicked(MouseEvent e) { 568 List<Layer> layers = layerSupplier.get(); 569 for (Layer l : layers) { 570 if (l instanceof GpxLayer) { 571 l.getColorProperty().put(color); 572 } 573 } 574 highlightColor(color); 575 } 576 }); 577 add(colorPanel, GBC.std().weight(1, 1).fill().insets(5)); 578 panels.put(color, colorPanel); 579 580 List<Color> colors = layerSupplier.get().stream().map(l -> l.getColorProperty().get()).distinct().collect(Collectors.toList()); 581 if (colors.size() == 1) { 582 highlightColor(colors.get(0)); 583 } 584 } 585 586 @Override 587 public void updateLayers(List<Layer> layers, boolean allVisible, boolean allHidden) { 588 List<Color> colors = layers.stream().filter(l -> l instanceof GpxLayer) 589 .map(l -> ((GpxLayer) l).getColorProperty().get()) 590 .distinct() 591 .collect(Collectors.toList()); 592 if (colors.size() == 1) { 593 setVisible(true); 594 highlightColor(colors.get(0)); 595 } else if (colors.size() > 1) { 596 setVisible(true); 597 highlightColor(null); 598 } else { 599 // no GPX layer 600 setVisible(false); 601 } 602 } 603 604 private void highlightColor(Color color) { 605 panels.values().forEach(panel -> panel.setBorder(NORMAL_BORDER)); 606 if (color != null) { 607 JPanel selected = panels.get(color); 608 if (selected != null) { 609 selected.setBorder(SELECTED_BORDER); 610 } 611 } 612 repaint(); 613 } 614 615 @Override 616 public JComponent getPanel() { 617 return this; 618 } 619 } 620}