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