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