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