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}