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