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}