001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.tools;
003
004import java.awt.Dimension;
005import java.awt.GraphicsConfiguration;
006import java.awt.GraphicsEnvironment;
007import java.awt.Image;
008import java.awt.geom.AffineTransform;
009import java.lang.reflect.Constructor;
010import java.lang.reflect.InvocationTargetException;
011import java.lang.reflect.Method;
012import java.util.Arrays;
013import java.util.Collections;
014import java.util.List;
015import java.util.function.Function;
016import java.util.function.UnaryOperator;
017import java.util.stream.Collectors;
018import java.util.stream.IntStream;
019
020import javax.swing.ImageIcon;
021
022/**
023 * Helper class for HiDPI support.
024 *
025 * Gives access to the class <code>BaseMultiResolutionImage</code> via reflection,
026 * in case it is on classpath. This is to be expected for Java 9, but not for Java 8 runtime.
027 *
028 * @since 12722
029 */
030public final class HiDPISupport {
031
032    private static final Class<? extends Image> baseMultiResolutionImageClass;
033    private static final Constructor<? extends Image> baseMultiResolutionImageConstructor;
034    private static final Method resolutionVariantsMethod;
035    private static final Method resolutionVariantMethod;
036
037    static {
038        baseMultiResolutionImageClass = initBaseMultiResolutionImageClass();
039        baseMultiResolutionImageConstructor = initBaseMultiResolutionImageConstructor();
040        resolutionVariantsMethod = initResolutionVariantsMethod();
041        resolutionVariantMethod = initResolutionVariantMethod();
042    }
043
044    private HiDPISupport() {
045        // Hide default constructor
046    }
047
048    /**
049     * Create a multi-resolution image from a base image and an {@link ImageResource}.
050     * <p>
051     * Will only return multi-resolution image, if HiDPI-mode is detected. Then
052     * the image stack will consist of the base image and one that fits the
053     * HiDPI scale of the main display.
054     * @param base the base image
055     * @param ir a corresponding image resource
056     * @param resizeMode how to size/resize the image
057     * @return multi-resolution image if necessary and possible, the base image otherwise
058     */
059    public static Image getMultiResolutionImage(Image base, ImageResource ir, ImageResizeMode resizeMode) {
060        double uiScale = getHiDPIScale();
061        if (uiScale != 1.0 && baseMultiResolutionImageConstructor != null) {
062            if (resizeMode == ImageResizeMode.BOUNDED) {
063                resizeMode = ImageResizeMode.AUTO;
064            }
065            ImageIcon zoomed = ir.getImageIconAlreadyScaled(new Dimension(
066                    (int) Math.round(base.getWidth(null) * uiScale),
067                    (int) Math.round(base.getHeight(null) * uiScale)), false, true, resizeMode);
068            Image mrImg = getMultiResolutionImage(Arrays.asList(base, zoomed.getImage()));
069            if (mrImg != null) return mrImg;
070        }
071        return base;
072    }
073
074    /**
075     * Create a multi-resolution image from a list of images.
076     * @param imgs the images, supposedly the same image at different resolutions,
077     * must not be empty
078     * @return corresponding multi-resolution image, if possible, the first image
079     * in the list otherwise
080     */
081    public static Image getMultiResolutionImage(List<Image> imgs) {
082        CheckParameterUtil.ensureThat(!imgs.isEmpty(), "imgs is empty");
083        if (baseMultiResolutionImageConstructor != null) {
084            try {
085                return baseMultiResolutionImageConstructor.newInstance((Object) imgs.toArray(new Image[0]));
086            } catch (InstantiationException | IllegalAccessException | InvocationTargetException ex) {
087                Logging.error("Unexpected error while instantiating object of class BaseMultiResolutionImage: " + ex);
088            }
089        }
090        return imgs.get(0);
091    }
092
093    /**
094     * Wrapper for the method <code>java.awt.image.BaseMultiResolutionImage#getBaseImage()</code>.
095     * <p>
096     * Will return the argument <code>img</code> unchanged, if it is not a multi-resolution image.
097     * @param img the image
098     * @return if <code>img</code> is a <code>java.awt.image.BaseMultiResolutionImage</code>,
099     * then the base image, otherwise the image itself
100     */
101    public static Image getBaseImage(Image img) {
102        if (baseMultiResolutionImageClass == null || resolutionVariantsMethod == null) {
103            return img;
104        }
105        if (baseMultiResolutionImageClass.isInstance(img)) {
106            try {
107                @SuppressWarnings("unchecked")
108                List<Image> imgVars = (List<Image>) resolutionVariantsMethod.invoke(img);
109                if (!imgVars.isEmpty()) {
110                    return imgVars.get(0);
111                }
112            } catch (IllegalAccessException | InvocationTargetException ex) {
113                Logging.error("Unexpected error while calling method: " + ex);
114            }
115        }
116        return img;
117    }
118
119    /**
120     * Wrapper for the method <code>java.awt.image.MultiResolutionImage#getResolutionVariants()</code>.
121     * <p>
122     * Will return the argument as a singleton list, in case it is not a multi-resolution image.
123     * @param img the image
124     * @return if <code>img</code> is a <code>java.awt.image.BaseMultiResolutionImage</code>,
125     * then the result of the method <code>#getResolutionVariants()</code>, otherwise the image
126     * itself as a singleton list
127     */
128    public static List<Image> getResolutionVariants(Image img) {
129        if (baseMultiResolutionImageClass == null || resolutionVariantsMethod == null) {
130            return Collections.singletonList(img);
131        }
132        if (baseMultiResolutionImageClass.isInstance(img)) {
133            try {
134                @SuppressWarnings("unchecked")
135                List<Image> imgVars = (List<Image>) resolutionVariantsMethod.invoke(img);
136                if (!imgVars.isEmpty()) {
137                    return imgVars;
138                }
139            } catch (IllegalAccessException | InvocationTargetException ex) {
140                Logging.error("Unexpected error while calling method: " + ex);
141            }
142        }
143        return Collections.singletonList(img);
144    }
145
146    /**
147     * Wrapper for method <code>java.awt.image.MultiResolutionImage#getResolutionVariant(double destImageWidth, double destImageHeight)</code>.
148     * <p>
149     * Will return the argument, in case it is not a multi-resolution image.
150     * @param img the image
151     * @param destImageWidth the width of the destination image
152     * @param destImageHeight the height of the destination image
153     * @return if <code>img</code> is a <code>java.awt.image.BaseMultiResolutionImage</code>,
154     * then the result of the method <code>#getResolutionVariant(destImageWidth, destImageHeight)</code>,
155     * otherwise the image itself
156     */
157    public static Image getResolutionVariant(Image img, double destImageWidth, double destImageHeight) {
158        if (baseMultiResolutionImageClass == null || resolutionVariantsMethod == null) {
159            return img;
160        }
161        if (baseMultiResolutionImageClass.isInstance(img)) {
162            try {
163                return (Image) resolutionVariantMethod.invoke(img, destImageWidth, destImageHeight);
164            } catch (IllegalAccessException | InvocationTargetException ex) {
165                Logging.error("Unexpected error while calling method: " + ex);
166            }
167        }
168        return img;
169    }
170
171    /**
172     * Detect the GUI scale for HiDPI mode.
173     * <p>
174     * This method may not work as expected for a multi-monitor setup. It will
175     * only take the default screen device into account.
176     * @return the GUI scale for HiDPI mode, a value of 1.0 means standard mode.
177     */
178    public static double getHiDPIScale() {
179        if (GraphicsEnvironment.isHeadless())
180            return 1.0;
181        GraphicsConfiguration gc = GraphicsEnvironment
182                .getLocalGraphicsEnvironment()
183                .getDefaultScreenDevice().
184                getDefaultConfiguration();
185        AffineTransform transform = gc.getDefaultTransform();
186        if (!Utils.equalsEpsilon(transform.getScaleX(), transform.getScaleY())) {
187            Logging.warn("Unexpected ui transform: " + transform);
188        }
189        return transform.getScaleX();
190    }
191
192    /**
193     * Perform an operation on multi-resolution images.
194     *
195     * When input image is not multi-resolution, it will simply apply the processor once.
196     * Otherwise, the processor will be called for each resolution variant and the
197     * resulting images assembled to become the output multi-resolution image.
198     * @param img input image, possibly multi-resolution
199     * @param processor processor taking a plain image as input and returning a single
200     * plain image as output
201     * @return multi-resolution image assembled from the output of calls to <code>processor</code>
202     * for each resolution variant
203     */
204    public static Image processMRImage(Image img, UnaryOperator<Image> processor) {
205        return processMRImages(Collections.singletonList(img), imgs -> processor.apply(imgs.get(0)));
206    }
207
208    /**
209     * Perform an operation on multi-resolution images.
210     *
211     * When input images are not multi-resolution, it will simply apply the processor once.
212     * Otherwise, the processor will be called for each resolution variant and the
213     * resulting images assembled to become the output multi-resolution image.
214     * @param imgs input images, possibly multi-resolution
215     * @param processor processor taking a list of plain images as input and returning
216     * a single plain image as output
217     * @return multi-resolution image assembled from the output of calls to <code>processor</code>
218     * for each resolution variant
219     */
220    public static Image processMRImages(List<Image> imgs, Function<List<Image>, Image> processor) {
221        CheckParameterUtil.ensureThat(!imgs.isEmpty(), "at least one element expected");
222        if (baseMultiResolutionImageClass != null) {
223            return processor.apply(imgs);
224        }
225        List<List<Image>> allVars = imgs.stream().map(HiDPISupport::getResolutionVariants).collect(Collectors.toList());
226        int maxVariants = allVars.stream().mapToInt(List<Image>::size).max().getAsInt();
227        if (maxVariants == 1)
228            return processor.apply(imgs);
229        List<Image> imgsProcessed = IntStream.range(0, maxVariants)
230                .mapToObj(
231                        k -> processor.apply(
232                                allVars.stream().map(vars -> vars.get(k)).collect(Collectors.toList())
233                        )
234                ).collect(Collectors.toList());
235        return getMultiResolutionImage(imgsProcessed);
236    }
237
238    @SuppressWarnings("unchecked")
239    private static Class<? extends Image> initBaseMultiResolutionImageClass() {
240        try {
241            return (Class<? extends Image>) Class.forName("java.awt.image.BaseMultiResolutionImage");
242        } catch (ClassNotFoundException ex) {
243            // class is not present in Java 8
244            Logging.trace(ex);
245            return null;
246        }
247    }
248
249    private static Constructor<? extends Image> initBaseMultiResolutionImageConstructor() {
250        try {
251            return baseMultiResolutionImageClass != null
252                    ? baseMultiResolutionImageClass.getConstructor(Image[].class)
253                    : null;
254        } catch (NoSuchMethodException ex) {
255            Logging.error("Cannot find expected constructor: " + ex);
256            return null;
257        }
258    }
259
260    private static Method initResolutionVariantsMethod() {
261        try {
262            return baseMultiResolutionImageClass != null
263                    ? baseMultiResolutionImageClass.getMethod("getResolutionVariants")
264                    : null;
265        } catch (NoSuchMethodException ex) {
266            Logging.error("Cannot find expected method: " + ex);
267            return null;
268        }
269    }
270
271    private static Method initResolutionVariantMethod() {
272        try {
273            return baseMultiResolutionImageClass != null
274                    ? baseMultiResolutionImageClass.getMethod("getResolutionVariant", Double.TYPE, Double.TYPE)
275                    : null;
276        } catch (NoSuchMethodException ex) {
277            Logging.error("Cannot find expected method: " + ex);
278            return null;
279        }
280    }
281}