Changeset 16486 in josm


Ignore:
Timestamp:
2020-05-23T21:19:14+02:00 (4 years ago)
Author:
simon04
Message:

fix #18694 - Wrongly rendered cursors on HiDPI screen (patch by johsin18)

Location:
trunk
Files:
6 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/org/openstreetmap/josm/tools/GuiSizesHelper.java

    r12846 r16486  
    5555    public static float getPixelDensity() {
    5656        return getScreenDPI() / 96f;
     57    }
     58
     59    /**
     60     * Sets coefficient of monitor pixel density.
     61     * @param pixelDensity coefficient of monitor pixel density to be set.
     62     */
     63    public static void setPixelDensity(float pixelDensity) {
     64        screenDPI = pixelDensity * 96f;
    5765    }
    5866
  • trunk/src/org/openstreetmap/josm/tools/HiDPISupport.java

    r16331 r16486  
    3333    private static final Constructor<? extends Image> baseMultiResolutionImageConstructor;
    3434    private static final Method resolutionVariantsMethod;
     35    private static final Method resolutionVariantMethod;
    3536
    3637    static {
     
    3839        baseMultiResolutionImageConstructor = initBaseMultiResolutionImageConstructor();
    3940        resolutionVariantsMethod = initResolutionVariantsMethod();
     41        resolutionVariantMethod = initResolutionVariantMethod();
    4042    }
    4143
     
    5759        double uiScale = getHiDPIScale();
    5860        if (uiScale != 1.0 && baseMultiResolutionImageConstructor != null) {
    59             ImageIcon zoomed = ir.getImageIcon(new Dimension(
     61            ImageIcon zoomed = ir.getImageIconAlreadyScaled(new Dimension(
    6062                    (int) Math.round(base.getWidth(null) * uiScale),
    61                     (int) Math.round(base.getHeight(null) * uiScale)), false);
     63                    (int) Math.round(base.getHeight(null) * uiScale)), false, true);
    6264            Image mrImg = getMultiResolutionImage(Arrays.asList(base, zoomed.getImage()));
    6365            if (mrImg != null) return mrImg;
     
    139141
    140142    /**
     143     * Wrapper for method <code>java.awt.image.MultiResolutionImage#getResolutionVariant(double destImageWidth, double destImageHeight)</code>.
     144     * <p>
     145     * Will return the argument, in case it is not a multi-resolution image.
     146     * @param img the image
     147     * @return if <code>img</code> is a <code>java.awt.image.BaseMultiResolutionImage</code>,
     148     * then the result of the method <code>#getResolutionVariant(destImageWidth, destImageHeight)</code>,
     149     * otherwise the image itself
     150     */
     151    public static Image getResolutionVariant(Image img, double destImageWidth, double destImageHeight) {
     152        if (baseMultiResolutionImageClass == null || resolutionVariantsMethod == null) {
     153            return img;
     154        }
     155        if (baseMultiResolutionImageClass.isInstance(img)) {
     156            try {
     157                return (Image) resolutionVariantMethod.invoke(img, destImageWidth, destImageHeight);
     158            } catch (IllegalAccessException | InvocationTargetException ex) {
     159                Logging.error("Unexpected error while calling method: " + ex);
     160            }
     161        }
     162        return img;
     163    }
     164
     165    /**
    141166     * Detect the GUI scale for HiDPI mode.
    142167     * <p>
     
    145170     * @return the GUI scale for HiDPI mode, a value of 1.0 means standard mode.
    146171     */
    147     private static double getHiDPIScale() {
     172    static double getHiDPIScale() {
    148173        if (GraphicsEnvironment.isHeadless())
    149174            return 1.0;
     
    237262        }
    238263    }
     264
     265    private static Method initResolutionVariantMethod() {
     266        try {
     267            return baseMultiResolutionImageClass != null
     268                    ? baseMultiResolutionImageClass.getMethod("getResolutionVariant", Double.TYPE, Double.TYPE)
     269                    : null;
     270        } catch (NoSuchMethodException ex) {
     271            Logging.error("Cannot find expected method: " + ex);
     272            return null;
     273        }
     274    }
    239275}
  • trunk/src/org/openstreetmap/josm/tools/ImageOverlay.java

    r12782 r16486  
    33
    44import java.awt.Dimension;
     5import java.awt.Image;
    56import java.awt.image.BufferedImage;
     7import java.util.List;
    68
    79import javax.swing.ImageIcon;
     
    6769    @Override
    6870    public BufferedImage process(BufferedImage ground) {
     71        return process(ground, false);
     72    }
     73
     74    /**
     75     * Handle overlay. The image passed as argument is modified!
     76     *
     77     * @param ground the base image for the overlay (gets modified!)
     78     * @param highResolution whether the high resolution variant should be used to overlay
     79     * @return the modified image (same as argument)
     80     */
     81    BufferedImage process(BufferedImage ground, boolean highResolution) {
    6982        /* get base dimensions for calculation */
    7083        int w = ground.getWidth();
     
    7891            height = (int) (h*(offsetBottom-offsetTop));
    7992        }
    80         ImageIcon overlay;
    8193        image = new ImageProvider(image).setMaxSize(new Dimension(width, height));
    82         overlay = image.get();
     94        ImageIcon overlay = image.get();
     95        if (highResolution) {
     96            List<Image> resolutionVariants = HiDPISupport.getResolutionVariants(overlay.getImage());
     97            if (resolutionVariants.size() >= 2) {
     98                overlay = new ImageIcon(resolutionVariants.get(1));
     99            }
     100        }
    83101        int x, y;
    84102        if (width == -1 && offsetLeft < 0) {
  • trunk/src/org/openstreetmap/josm/tools/ImageProvider.java

    r16436 r16486  
    13181318     */
    13191319    public static Cursor getCursor(String name, String overlay) {
    1320         ImageIcon img = get("cursor", name);
    1321         if (overlay != null) {
    1322             img = new ImageProvider("cursor", name).setMaxSize(ImageSizes.CURSOR)
    1323                 .addOverlay(new ImageOverlay(new ImageProvider("cursor/modifier/" + overlay)
    1324                     .setMaxSize(ImageSizes.CURSOROVERLAY))).get();
    1325         }
    13261320        if (GraphicsEnvironment.isHeadless()) {
    13271321            Logging.debug("Cursors are not available in headless mode. Returning null for ''{0}''", name);
    13281322            return null;
    13291323        }
    1330         return Toolkit.getDefaultToolkit().createCustomCursor(img.getImage(),
    1331                 "crosshair".equals(name) ? new Point(10, 10) : new Point(3, 2), "Cursor");
     1324
     1325        Point hotSpot = new Point();
     1326        Image image = getCursorImage(name, overlay, hotSpot);
     1327
     1328        return Toolkit.getDefaultToolkit().createCustomCursor(image, hotSpot, name);
     1329    }
     1330
     1331    /**
     1332     * Load a cursor image with a given file name, optionally decorated with an overlay image
     1333     *
     1334     * @param name the cursor image filename in "cursor" directory
     1335     * @param overlay optional overlay image
     1336     * @param hotSpot will be set to the properly scaled hotspot of the cursor
     1337     * @return cursor with a given file name, optionally decorated with an overlay image
     1338     */
     1339    static Image getCursorImage(String name, String overlay, /* out */ Point hotSpot) {
     1340        ImageProvider imageProvider = new ImageProvider("cursor", name);
     1341        if (overlay != null) {
     1342            imageProvider
     1343                .setMaxSize(ImageSizes.CURSOR)
     1344                .addOverlay(new ImageOverlay(new ImageProvider("cursor/modifier/" + overlay)
     1345                                                .setMaxSize(ImageSizes.CURSOROVERLAY)));
     1346        }
     1347        hotSpot.setLocation("crosshair".equals(name) ? new Point(10, 10) : new Point(3, 2));
     1348        ImageIcon imageIcon = imageProvider.get();
     1349        Image image = imageIcon.getImage();
     1350        int width = image.getWidth(null);
     1351        int height = image.getHeight(null);
     1352
     1353        // AWT will resize the cursor to bestCursorSize internally anyway, but miss to scale the hotspot as well
     1354        // (bug JDK-8238734).  So let's do this ourselves, and also scale the hotspot accordingly.
     1355        Dimension bestCursorSize = Toolkit.getDefaultToolkit().getBestCursorSize(width, height);
     1356        if (bestCursorSize.width != 0 && bestCursorSize.height != 0) {
     1357            // In principle, we could pass the MultiResolutionImage itself to AWT, but due to bug JDK-8240568,
     1358            // this results in bad alpha blending and thus jaggy edges.  So let's select the best variant ourselves.
     1359            image = HiDPISupport.getResolutionVariant(image, bestCursorSize.width, bestCursorSize.height);
     1360            if (bestCursorSize.width != image.getWidth(null) || bestCursorSize.height != image.getHeight(null)) {
     1361                image = image.getScaledInstance(bestCursorSize.width, bestCursorSize.height, Image.SCALE_DEFAULT);
     1362            }
     1363
     1364            hotSpot.x = hotSpot.x * bestCursorSize.width / width;
     1365            hotSpot.y = hotSpot.y * bestCursorSize.height / height;
     1366        }
     1367
     1368        return image;
    13321369    }
    13331370
  • trunk/src/org/openstreetmap/josm/tools/ImageResource.java

    r16207 r16486  
    154154     * @since 12722
    155155     */
    156     public ImageIcon getImageIcon(Dimension dim, boolean multiResolution) {
     156    ImageIcon getImageIcon(Dimension dim, boolean multiResolution) {
     157        return getImageIconAlreadyScaled(GuiSizesHelper.getDimensionDpiAdjusted(dim), multiResolution, false);
     158    }
     159
     160    /**
     161     * Get an ImageIcon object for the image of this resource. A potential UI scaling is assumed
     162     * to be already taken care of, so dim is already scaled accordingly.
     163     * @param  dim The requested dimensions. Use (-1,-1) for the original size and (width, -1)
     164     *         to set the width, but otherwise scale the image proportionally.
     165     * @param  multiResolution If true, return a multi-resolution image
     166     * (java.awt.image.MultiResolutionImage in Java 9), otherwise a plain {@link BufferedImage}.
     167     * When running Java 8, this flag has no effect and a plain image will be returned in any case.
     168     * @param highResolution whether the high resolution variant should be used for overlays
     169     * @return ImageIcon object for the image of this resource, scaled according to dim
     170     */
     171    ImageIcon getImageIconAlreadyScaled(Dimension dim, boolean multiResolution, boolean highResolution) {
    157172        CheckParameterUtil.ensureThat((dim.width > 0 || dim.width == -1) && (dim.height > 0 || dim.height == -1),
    158173                () -> dim + " is invalid");
     174
    159175        BufferedImage img = imgCache.get(dim);
    160176        if (img == null) {
    161177            if (svg != null) {
    162                 Dimension realDim = GuiSizesHelper.getDimensionDpiAdjusted(dim);
    163                 img = ImageProvider.createImageFromSvg(svg, realDim);
     178                img = ImageProvider.createImageFromSvg(svg, dim);
    164179                if (img == null) {
    165180                    return null;
     
    168183                if (baseImage == null) throw new AssertionError();
    169184
    170                 int realWidth = GuiSizesHelper.getSizeDpiAdjusted(dim.width);
    171                 int realHeight = GuiSizesHelper.getSizeDpiAdjusted(dim.height);
    172185                ImageIcon icon = new ImageIcon(baseImage);
    173                 if (realWidth == -1 && realHeight == -1) {
    174                     realWidth = GuiSizesHelper.getSizeDpiAdjusted(icon.getIconWidth());
    175                     realHeight = GuiSizesHelper.getSizeDpiAdjusted(icon.getIconHeight());
    176                 } else if (realWidth == -1) {
    177                     realWidth = Math.max(1, icon.getIconWidth() * realHeight / icon.getIconHeight());
    178                 } else if (realHeight == -1) {
    179                     realHeight = Math.max(1, icon.getIconHeight() * realWidth / icon.getIconWidth());
     186                if (dim.width == -1 && dim.height == -1) {
     187                    dim.width = GuiSizesHelper.getSizeDpiAdjusted(icon.getIconWidth());
     188                    dim.height = GuiSizesHelper.getSizeDpiAdjusted(icon.getIconHeight());
     189                } else if (dim.width == -1) {
     190                    dim.width = Math.max(1, icon.getIconWidth() * dim.height / icon.getIconHeight());
     191                } else if (dim.height == -1) {
     192                    dim.height = Math.max(1, icon.getIconHeight() * dim.width / icon.getIconWidth());
    180193                }
    181                 Image i = icon.getImage().getScaledInstance(realWidth, realHeight, Image.SCALE_SMOOTH);
    182                 img = new BufferedImage(realWidth, realHeight, BufferedImage.TYPE_INT_ARGB);
     194                Image i = icon.getImage().getScaledInstance(dim.width, dim.height, Image.SCALE_SMOOTH);
     195                img = new BufferedImage(dim.width, dim.height, BufferedImage.TYPE_INT_ARGB);
    183196                img.getGraphics().drawImage(i, 0, 0, null);
    184197            }
    185198            if (overlayInfo != null) {
    186199                for (ImageOverlay o : overlayInfo) {
    187                     o.process(img);
     200                    o.process(img, highResolution);
    188201                }
    189202            }
  • trunk/test/unit/org/openstreetmap/josm/tools/ImageProviderTest.java

    r15897 r16486  
    22package org.openstreetmap.josm.tools;
    33
     4import static java.awt.image.BufferedImage.TYPE_INT_ARGB;
    45import static org.junit.Assert.assertEquals;
    56import static org.junit.Assert.assertFalse;
    67import static org.junit.Assert.assertNotNull;
    78import static org.junit.Assert.assertNull;
    8 
     9import static org.junit.Assert.assertTrue;
     10
     11import java.awt.Color;
    912import java.awt.Dimension;
     13import java.awt.GraphicsEnvironment;
     14import java.awt.GridLayout;
     15import java.awt.Graphics;
     16import java.awt.Image;
     17import java.awt.Point;
     18import java.awt.Toolkit;
    1019import java.awt.Transparency;
     20import java.awt.event.MouseEvent;
     21import java.awt.event.MouseListener;
    1122import java.awt.image.BufferedImage;
    1223import java.io.File;
    1324import java.io.IOException;
    1425import java.util.EnumSet;
     26import java.util.List;
    1527import java.util.logging.Handler;
    1628import java.util.logging.LogRecord;
     
    1830
    1931import javax.swing.ImageIcon;
    20 
     32import javax.swing.JFrame;
     33import javax.swing.JPanel;
     34
     35import org.junit.Before;
    2136import org.junit.BeforeClass;
     37import org.junit.Ignore;
    2238import org.junit.Rule;
    2339import org.junit.Test;
     
    7288    }
    7389
     90    @Before
     91    public void resetPixelDensity() {
     92        GuiSizesHelper.setPixelDensity(1.0f);
     93    }
     94
    7495    /**
    7596     * Non-regression test for ticket <a href="https://josm.openstreetmap.de/ticket/9984">#9984</a>
     
    149170        assertNotNull(ImageProvider.getPadded(OsmUtils.createPrimitive("relation type=route route=railway"), iconSize, noDefault));
    150171    }
     172
     173    /**
     174     * Test getting a bounded icon given some UI scaling configured.
     175     */
     176    @Test
     177    public void testGetImageIconBounded() {
     178        int scale = 2;
     179        GuiSizesHelper.setPixelDensity(scale);
     180
     181        ImageProvider imageProvider = new ImageProvider("open").setOptional(true);
     182        ImageResource resource = imageProvider.getResource();
     183        Dimension iconDimension = ImageProvider.ImageSizes.SMALLICON.getImageDimension();
     184        ImageIcon icon = resource.getImageIconBounded(iconDimension);
     185        Image image = icon.getImage();
     186        List<Image> resolutionVariants = HiDPISupport.getResolutionVariants(image);
     187        if (resolutionVariants.size() > 1) {
     188            assertEquals(2, resolutionVariants.size());
     189            int expectedVirtualWidth = ImageProvider.ImageSizes.SMALLICON.getVirtualWidth();
     190            assertEquals(expectedVirtualWidth * scale, resolutionVariants.get(0).getWidth(null));
     191            assertEquals((int) Math.round(expectedVirtualWidth * scale * HiDPISupport.getHiDPIScale()),
     192                         resolutionVariants.get(1).getWidth(null));
     193        }
     194    }
     195
     196    public static final int ORIGINAL_CURSOR_SIZE = 32;
     197
     198    /**
     199     * Test getting an image for a crosshair cursor.
     200     */
     201    @Test
     202    public void testGetCursorImageForCrosshair() {
     203        if (GraphicsEnvironment.isHeadless()) {
     204            // TODO mock Toolkit.getDefaultToolkit().getBestCursorSize()
     205            return;
     206        }
     207        Point hotSpot = new Point();
     208        Image image = ImageProvider.getCursorImage("crosshair", null, hotSpot);
     209        assertCursorDimensionsCorrect(new Point.Double(10.0, 10.0), image, hotSpot);
     210    }
     211
     212    /**
     213     * Test getting an image for a custom cursor with overlay.
     214     */
     215    @Test
     216    public void testGetCursorImageWithOverlay() {
     217        if (GraphicsEnvironment.isHeadless()) {
     218            // TODO mock Toolkit.getDefaultToolkit().getBestCursorSize()
     219            return;
     220        }
     221        Point hotSpot = new Point();
     222        Image image = ImageProvider.getCursorImage("normal", "selection", hotSpot);
     223        assertCursorDimensionsCorrect(new Point.Double(3.0, 2.0), image, hotSpot);
     224        BufferedImage bufferedImage = new BufferedImage(image.getWidth(null), image.getWidth(null), TYPE_INT_ARGB);
     225        bufferedImage.getGraphics().drawImage(image, 0, 0, null);
     226
     227        // check that the square of 1/4 size right lower to the center has some non-emtpy pixels
     228        boolean nonEmptyPixelExistsRightLowerToCenter = false;
     229        for (int x = image.getWidth(null) / 2; x < image.getWidth(null) * 3 / 4; ++x) {
     230            for (int y = image.getHeight(null) / 2; y < image.getWidth(null) * 3 / 4; ++y) {
     231                if (bufferedImage.getRGB(x, y) != 0)
     232                    nonEmptyPixelExistsRightLowerToCenter = true;
     233            }
     234        }
     235        assertTrue(nonEmptyPixelExistsRightLowerToCenter);
     236    }
     237
     238    private void assertCursorDimensionsCorrect(Point.Double originalHotspot, Image image, Point hotSpot) {
     239        Dimension bestCursorSize = Toolkit.getDefaultToolkit().getBestCursorSize(ORIGINAL_CURSOR_SIZE, ORIGINAL_CURSOR_SIZE);
     240        Image bestCursorImage = HiDPISupport.getResolutionVariant(image, bestCursorSize.width, bestCursorSize.height);
     241        int bestCursorImageWidth = bestCursorImage.getWidth(null);
     242        assertEquals((int) Math.round(bestCursorSize.getWidth()), bestCursorImageWidth);
     243        int bestCursorImageHeight = bestCursorImage.getHeight(null);
     244        assertEquals((int) Math.round(bestCursorSize.getHeight()), bestCursorImageHeight);
     245        assertEquals(originalHotspot.x / ORIGINAL_CURSOR_SIZE * bestCursorImageWidth, hotSpot.x, 1 /* at worst one pixel off */);
     246        assertEquals(originalHotspot.y / ORIGINAL_CURSOR_SIZE * bestCursorImageHeight, hotSpot.y, 1 /* at worst one pixel off */);
     247    }
     248
     249
     250    /**
     251     * Test getting a cursor
     252     */
     253    @Ignore("manual execution only, as the look of the cursor cannot be checked automatedly")
     254    @Test
     255    public void testGetCursor() throws InterruptedException {
     256        JFrame frame = new JFrame();
     257        frame.setSize(500, 500);
     258        frame.setLayout(new GridLayout(2, 2));
     259        JPanel leftUpperPanel = new JPanel(), rightUpperPanel = new JPanel(), leftLowerPanel = new JPanel(), rightLowerPanel = new JPanel();
     260        leftUpperPanel.setBackground(Color.DARK_GRAY);
     261        rightUpperPanel.setBackground(Color.DARK_GRAY);
     262        leftLowerPanel.setBackground(Color.DARK_GRAY);
     263        rightLowerPanel.setBackground(Color.DARK_GRAY);
     264        frame.add(leftUpperPanel);
     265        frame.add(rightUpperPanel);
     266        frame.add(leftLowerPanel);
     267        frame.add(rightLowerPanel);
     268
     269        leftUpperPanel.setCursor(ImageProvider.getCursor("normal", "select_add")); // contains diagonal sensitive to alpha blending
     270        rightUpperPanel.setCursor(ImageProvider.getCursor("crosshair", "joinway")); // combination of overlay and hotspot not top left
     271        leftLowerPanel.setCursor(ImageProvider.getCursor("hand", "parallel_remove")); // reasonably nice bitmap cursor
     272        rightLowerPanel.setCursor(ImageProvider.getCursor("rotate", null)); // ugly bitmap cursor, cannot do much here
     273
     274        frame.setVisible(true);
     275
     276        // hover over the four quadrant to observe different cursors
     277
     278        // draw red dot at hotspot when clicking
     279        frame.addMouseListener(new MouseListener() {
     280            @Override
     281            public void mouseClicked(MouseEvent e) {
     282                Graphics graphics = frame.getGraphics();
     283                graphics.setColor(Color.RED);
     284                graphics.drawRect(e.getX(), e.getY(), 1, 1);
     285            }
     286
     287            @Override
     288            public void mousePressed(MouseEvent e) { }
     289
     290            @Override
     291            public void mouseReleased(MouseEvent e) { }
     292
     293            @Override
     294            public void mouseEntered(MouseEvent e) { }
     295
     296            @Override
     297            public void mouseExited(MouseEvent e) { }
     298        });
     299        Thread.sleep(9000); // test would time out after 10s
     300    }
    151301}
Note: See TracChangeset for help on using the changeset viewer.