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.Component;
007import java.awt.GridBagLayout;
008import java.awt.event.ActionEvent;
009import java.awt.event.MouseWheelEvent;
010import java.util.ArrayList;
011import java.util.Collection;
012import java.util.List;
013
014import javax.swing.AbstractAction;
015import javax.swing.BorderFactory;
016import javax.swing.ImageIcon;
017import javax.swing.JCheckBox;
018import javax.swing.JLabel;
019import javax.swing.JMenuItem;
020import javax.swing.JPanel;
021import javax.swing.JPopupMenu;
022import javax.swing.JSlider;
023
024import org.openstreetmap.josm.Main;
025import org.openstreetmap.josm.gui.SideButton;
026import org.openstreetmap.josm.gui.dialogs.IEnabledStateUpdating;
027import org.openstreetmap.josm.gui.dialogs.LayerListDialog.LayerListModel;
028import org.openstreetmap.josm.gui.layer.ImageryLayer;
029import org.openstreetmap.josm.gui.layer.Layer;
030import org.openstreetmap.josm.gui.layer.Layer.LayerAction;
031import org.openstreetmap.josm.gui.layer.imagery.ImageryFilterSettings;
032import org.openstreetmap.josm.tools.GBC;
033import org.openstreetmap.josm.tools.ImageProvider;
034import org.openstreetmap.josm.tools.Utils;
035
036/**
037 * This is a menu that includes all settings for the layer visibility. It combines gamma/opacity sliders and the visible-checkbox.
038 *
039 * @author Michael Zangl
040 */
041public final class LayerVisibilityAction extends AbstractAction implements IEnabledStateUpdating, LayerAction {
042    private static final int SLIDER_STEPS = 100;
043    /**
044     * Steps the value is changed by a mouse wheel change (one full click)
045     */
046    private static final int SLIDER_WHEEL_INCREMENT = 5;
047    private static final double MAX_SHARPNESS_FACTOR = 2;
048    private static final double MAX_COLORFUL_FACTOR = 2;
049    private final LayerListModel model;
050    private final JPopupMenu popup;
051    private SideButton sideButton;
052    private final JCheckBox visibilityCheckbox;
053    final OpacitySlider opacitySlider = new OpacitySlider();
054    private final ArrayList<FilterSlider<?>> sliders = new ArrayList<>();
055
056    /**
057     * Creates a new {@link LayerVisibilityAction}
058     * @param model The list to get the selection from.
059     */
060    public LayerVisibilityAction(LayerListModel model) {
061        this.model = model;
062        popup = new JPopupMenu();
063        // prevent popup close on mouse wheel move
064        popup.addMouseWheelListener(MouseWheelEvent::consume);
065
066        // just to add a border
067        JPanel content = new JPanel();
068        popup.add(content);
069        content.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
070        content.setLayout(new GridBagLayout());
071
072        new ImageProvider("dialogs/layerlist", "visibility").getResource().attachImageIcon(this, true);
073        putValue(SHORT_DESCRIPTION, tr("Change visibility of the selected layer."));
074
075        visibilityCheckbox = new JCheckBox(tr("Show layer"));
076        visibilityCheckbox.addChangeListener(e -> setVisibleFlag(visibilityCheckbox.isSelected()));
077        content.add(visibilityCheckbox, GBC.eop());
078
079        addSlider(content, opacitySlider);
080        addSlider(content, new ColorfulnessSlider());
081        addSlider(content, new GammaFilterSlider());
082        addSlider(content, new SharpnessSlider());
083    }
084
085    private void addSlider(JPanel content, FilterSlider<?> slider) {
086        // wrap to a common content pane to allow for mouse wheel listener on label.
087        JPanel container = new JPanel(new GridBagLayout());
088        container.add(new JLabel(slider.getIcon()), GBC.std().span(1, 2).insets(0, 0, 5, 0));
089        container.add(new JLabel(slider.getLabel()), GBC.eol());
090        container.add(slider, GBC.eol());
091        content.add(container, GBC.eop());
092
093        container.addMouseWheelListener(slider::mouseWheelMoved);
094        sliders.add(slider);
095    }
096
097    void setVisibleFlag(boolean visible) {
098        for (Layer l : model.getSelectedLayers()) {
099            l.setVisible(visible);
100        }
101        updateValues();
102    }
103
104    @Override
105    public void actionPerformed(ActionEvent e) {
106        updateValues();
107        if (e.getSource() == sideButton) {
108            popup.show(sideButton, 0, sideButton.getHeight());
109        } else {
110            // Action can be trigger either by opacity button or by popup menu (in case toggle buttons are hidden).
111            // In that case, show it in the middle of screen (because opacityButton is not visible)
112            popup.show(Main.parent, Main.parent.getWidth() / 2, (Main.parent.getHeight() - popup.getHeight()) / 2);
113        }
114    }
115
116    void updateValues() {
117        List<Layer> layers = model.getSelectedLayers();
118
119        visibilityCheckbox.setEnabled(!layers.isEmpty());
120        boolean allVisible = true;
121        boolean allHidden = true;
122        for (Layer l : layers) {
123            allVisible &= l.isVisible();
124            allHidden &= !l.isVisible();
125        }
126        // TODO: Indicate tristate.
127        visibilityCheckbox.setSelected(allVisible && !allHidden);
128
129        for (FilterSlider<?> slider : sliders) {
130            slider.updateSlider(layers, allHidden);
131        }
132    }
133
134    @Override
135    public boolean supportLayers(List<Layer> layers) {
136        return !layers.isEmpty();
137    }
138
139    @Override
140    public Component createMenuComponent() {
141        return new JMenuItem(this);
142    }
143
144    @Override
145    public void updateEnabledState() {
146        setEnabled(!model.getSelectedLayers().isEmpty());
147    }
148
149    /**
150     * Sets the corresponding side button.
151     * @param sideButton the corresponding side button
152     */
153    public void setCorrespondingSideButton(SideButton sideButton) {
154        this.sideButton = sideButton;
155    }
156
157    /**
158     * This is a slider for a filter value.
159     * @author Michael Zangl
160     *
161     * @param <T> The layer type.
162     */
163    private abstract class FilterSlider<T extends Layer> extends JSlider {
164        private final double minValue;
165        private final double maxValue;
166        private final Class<T> layerClassFilter;
167
168        /**
169         * Create a new filter slider.
170         * @param minValue The minimum value to map to the left side.
171         * @param maxValue The maximum value to map to the right side.
172         * @param layerClassFilter The type of layer influenced by this filter.
173         */
174        FilterSlider(double minValue, double maxValue, Class<T> layerClassFilter) {
175            super(JSlider.HORIZONTAL);
176            this.minValue = minValue;
177            this.maxValue = maxValue;
178            this.layerClassFilter = layerClassFilter;
179            setMaximum(SLIDER_STEPS);
180            int tick = convertFromRealValue(1);
181            setMinorTickSpacing(tick);
182            setMajorTickSpacing(tick);
183            setPaintTicks(true);
184
185            addChangeListener(e -> onStateChanged());
186        }
187
188        /**
189         * Called whenever the state of the slider was changed.
190         * @see #getValueIsAdjusting()
191         * @see #getRealValue()
192         */
193        protected void onStateChanged() {
194            Collection<T> layers = filterLayers(model.getSelectedLayers());
195            for (T layer : layers) {
196                applyValueToLayer(layer);
197            }
198        }
199
200        protected void mouseWheelMoved(MouseWheelEvent e) {
201            e.consume();
202            if (!isEnabled()) {
203                // ignore mouse wheel in disabled state.
204                return;
205            }
206            double rotation = -1 * e.getPreciseWheelRotation();
207            double destinationValue = getValue() + rotation * SLIDER_WHEEL_INCREMENT;
208            if (rotation < 0) {
209                destinationValue = Math.floor(destinationValue);
210            } else {
211                destinationValue = Math.ceil(destinationValue);
212            }
213            setValue(Utils.clamp((int) destinationValue, getMinimum(), getMaximum()));
214        }
215
216        protected void applyValueToLayer(T layer) {
217        }
218
219        protected double getRealValue() {
220            return convertToRealValue(getValue());
221        }
222
223        protected double convertToRealValue(int value) {
224            double s = (double) value / SLIDER_STEPS;
225            return s * maxValue + (1-s) * minValue;
226        }
227
228        protected void setRealValue(double value) {
229            setValue(convertFromRealValue(value));
230        }
231
232        protected int convertFromRealValue(double value) {
233            int i = (int) ((value - minValue) / (maxValue - minValue) * SLIDER_STEPS + .5);
234            return Utils.clamp(i, getMinimum(), getMaximum());
235        }
236
237        public abstract ImageIcon getIcon();
238
239        public abstract String getLabel();
240
241        public void updateSlider(List<Layer> layers, boolean allHidden) {
242            Collection<? extends Layer> usedLayers = filterLayers(layers);
243            if (usedLayers.isEmpty() || allHidden) {
244                setEnabled(false);
245            } else {
246                setEnabled(true);
247                updateSliderWhileEnabled(usedLayers, allHidden);
248            }
249        }
250
251        protected Collection<T> filterLayers(List<Layer> layers) {
252            return Utils.filteredCollection(layers, layerClassFilter);
253        }
254
255        protected abstract void updateSliderWhileEnabled(Collection<? extends Layer> usedLayers, boolean allHidden);
256    }
257
258    /**
259     * This slider allows you to change the opacity of a layer.
260     *
261     * @author Michael Zangl
262     * @see Layer#setOpacity(double)
263     */
264    class OpacitySlider extends FilterSlider<Layer> {
265        /**
266         * Creaate a new {@link OpacitySlider}.
267         */
268        OpacitySlider() {
269            super(0, 1, Layer.class);
270            setToolTipText(tr("Adjust opacity of the layer."));
271        }
272
273        @Override
274        protected void onStateChanged() {
275            if (getRealValue() <= 0.001 && !getValueIsAdjusting()) {
276                setVisibleFlag(false);
277            } else {
278                super.onStateChanged();
279            }
280        }
281
282        @Override
283        protected void mouseWheelMoved(MouseWheelEvent e) {
284            if (!isEnabled() && !filterLayers(model.getSelectedLayers()).isEmpty() && e.getPreciseWheelRotation() < 0) {
285                // make layer visible and set the value.
286                // this allows users to use the mouse wheel to make the layer visible if it was hidden previously.
287                e.consume();
288                setVisibleFlag(true);
289            } else {
290                super.mouseWheelMoved(e);
291            }
292        }
293
294        @Override
295        protected void applyValueToLayer(Layer layer) {
296            layer.setOpacity(getRealValue());
297        }
298
299        @Override
300        protected void updateSliderWhileEnabled(Collection<? extends Layer> usedLayers, boolean allHidden) {
301            double opacity = 0;
302            for (Layer l : usedLayers) {
303                opacity += l.getOpacity();
304            }
305            opacity /= usedLayers.size();
306            if (opacity == 0) {
307                opacity = 1;
308                setVisibleFlag(true);
309            }
310            setRealValue(opacity);
311        }
312
313        @Override
314        public String getLabel() {
315            return tr("Opacity");
316        }
317
318        @Override
319        public ImageIcon getIcon() {
320            return ImageProvider.get("dialogs/layerlist", "transparency");
321        }
322
323        @Override
324        public String toString() {
325            return "OpacitySlider [getRealValue()=" + getRealValue() + ']';
326        }
327    }
328
329    /**
330     * This slider allows you to change the gamma value of a layer.
331     *
332     * @author Michael Zangl
333     * @see ImageryFilterSettings#setGamma(double)
334     */
335    private class GammaFilterSlider extends FilterSlider<ImageryLayer> {
336
337        /**
338         * Create a new {@link GammaFilterSlider}
339         */
340        GammaFilterSlider() {
341            super(-1, 1, ImageryLayer.class);
342            setToolTipText(tr("Adjust gamma value of the layer."));
343        }
344
345        @Override
346        protected void updateSliderWhileEnabled(Collection<? extends Layer> usedLayers, boolean allHidden) {
347            double gamma = ((ImageryLayer) usedLayers.iterator().next()).getFilterSettings().getGamma();
348            setRealValue(mapGammaToInterval(gamma));
349        }
350
351        @Override
352        protected void applyValueToLayer(ImageryLayer layer) {
353            layer.getFilterSettings().setGamma(mapIntervalToGamma(getRealValue()));
354        }
355
356        @Override
357        public ImageIcon getIcon() {
358           return ImageProvider.get("dialogs/layerlist", "gamma");
359        }
360
361        @Override
362        public String getLabel() {
363            return tr("Gamma");
364        }
365
366        /**
367         * Maps a number x from the range (-1,1) to a gamma value.
368         * Gamma value is in the range (0, infinity).
369         * Gamma values of 3 and 1/3 have opposite effects, so the mapping
370         * should be symmetric in that sense.
371         * @param x the slider value in the range (-1,1)
372         * @return the gamma value
373         */
374        private double mapIntervalToGamma(double x) {
375            // properties of the mapping:
376            // g(-1) = 0
377            // g(0) = 1
378            // g(1) = infinity
379            // g(-x) = 1 / g(x)
380            return (1 + x) / (1 - x);
381        }
382
383        private double mapGammaToInterval(double gamma) {
384            return (gamma - 1) / (gamma + 1);
385        }
386    }
387
388    /**
389     * This slider allows you to change the sharpness of a layer.
390     *
391     * @author Michael Zangl
392     * @see ImageryFilterSettings#setSharpenLevel(double)
393     */
394    private class SharpnessSlider extends FilterSlider<ImageryLayer> {
395
396        /**
397         * Creates a new {@link SharpnessSlider}
398         */
399        SharpnessSlider() {
400            super(0, MAX_SHARPNESS_FACTOR, ImageryLayer.class);
401            setToolTipText(tr("Adjust sharpness/blur value of the layer."));
402        }
403
404        @Override
405        protected void updateSliderWhileEnabled(Collection<? extends Layer> usedLayers, boolean allHidden) {
406            setRealValue(((ImageryLayer) usedLayers.iterator().next()).getFilterSettings().getSharpenLevel());
407        }
408
409        @Override
410        protected void applyValueToLayer(ImageryLayer layer) {
411            layer.getFilterSettings().setSharpenLevel(getRealValue());
412        }
413
414        @Override
415        public ImageIcon getIcon() {
416           return ImageProvider.get("dialogs/layerlist", "sharpness");
417        }
418
419        @Override
420        public String getLabel() {
421            return tr("Sharpness");
422        }
423    }
424
425    /**
426     * This slider allows you to change the colorfulness of a layer.
427     *
428     * @author Michael Zangl
429     * @see ImageryFilterSettings#setColorfulness(double)
430     */
431    private class ColorfulnessSlider extends FilterSlider<ImageryLayer> {
432
433        /**
434         * Create a new {@link ColorfulnessSlider}
435         */
436        ColorfulnessSlider() {
437            super(0, MAX_COLORFUL_FACTOR, ImageryLayer.class);
438            setToolTipText(tr("Adjust colorfulness of the layer."));
439        }
440
441        @Override
442        protected void updateSliderWhileEnabled(Collection<? extends Layer> usedLayers, boolean allHidden) {
443            setRealValue(((ImageryLayer) usedLayers.iterator().next()).getFilterSettings().getColorfulness());
444        }
445
446        @Override
447        protected void applyValueToLayer(ImageryLayer layer) {
448            layer.getFilterSettings().setColorfulness(getRealValue());
449        }
450
451        @Override
452        public ImageIcon getIcon() {
453           return ImageProvider.get("dialogs/layerlist", "colorfulness");
454        }
455
456        @Override
457        public String getLabel() {
458            return tr("Colorfulness");
459        }
460    }
461}