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