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