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