ScalableImageIcon.java

package swingtree.style;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import swingtree.UI;
import swingtree.layout.Size;

import javax.swing.ImageIcon;
import java.awt.Image;
import java.util.Objects;

/**
 *  A wrapper for {@link ImageIcon} that automatically scales the image to the
 *  current {@link UI#scale()} value defined in the current {@link swingtree.SwingTree}
 *  library context singleton.<br>
 *
 */
public final class ScalableImageIcon extends ImageIcon
{
    private static final Logger log = LoggerFactory.getLogger(ScalableImageIcon.class);

    /**
     *  A factory method that creates a new {@link ScalableImageIcon} that will render
     *  the supplied {@link ImageIcon} using the given base size scaled according to the current DPI settings.
     *  <p>
     *      If the given {@link ImageIcon} is already a {@link ScalableImageIcon},
     *      a new instance will be created from the existing one
     *      using {@link ScalableImageIcon#withSize(Size)}.
     *  </p>
     * @param size The size to render the icon at.
     * @param icon The icon to render.
     * @return A new {@link ScalableImageIcon} that will render the image
     *          scaled according to the current DPI settings.
     */
    public static ScalableImageIcon of( Size size, ImageIcon icon ) {
        Objects.requireNonNull(size);
        Objects.requireNonNull(icon);
        if ( icon instanceof ScalableImageIcon )
            return ((ScalableImageIcon) icon).withSize(size);
        return new ScalableImageIcon(size, icon);
    }

    private final ImageIcon _sourceIcon;
    private final Size      _relativeScale;
    private final Size      _baseSize;

    private ImageIcon _scaled;
    private float     _currentScale;

    private ScalableImageIcon( Size size, ImageIcon original ) {
        Objects.requireNonNull(size);
        Objects.requireNonNull(original);
        Size relativeScale = Size.unknown();
        double targetWidth = -1;
        double targetHeight = -1;
        try {
            double originalIconWidth = original.getIconWidth();
            double originalIconHeight = original.getIconHeight();
            double ratio = originalIconWidth / originalIconHeight;
            if (size.hasPositiveWidth() && size.hasPositiveHeight()) {
                targetWidth = size.width().orElse(0.0f);
                targetHeight = size.height().orElse(0.0f);
            } else if (size.hasPositiveWidth()) {
                targetWidth = size.width().orElse(0.0f);
                targetHeight = targetWidth / ratio;
            } else if (size.hasPositiveHeight()) {
                targetHeight = size.height().orElse(0.0f);
                targetWidth = targetHeight * ratio;
            } else {
                targetWidth = originalIconWidth;
                targetHeight = originalIconHeight;
            }
            relativeScale = Size.of(targetWidth / originalIconWidth, targetHeight / originalIconHeight);
        } catch ( Exception e ) {
            log.error("An error occurred while calculating the size of a ScalableImageIcon.", e);
        }
        _baseSize      = Size.of((int) targetWidth, (int) targetHeight);
        _sourceIcon = original;
        _relativeScale = relativeScale;
        _currentScale  = UI.scale();
        _scaled        = _scaleTo(_currentScale, _relativeScale, original);
    }

    private ImageIcon _scaleTo( float scale, Size relativeScale, ImageIcon original ) {
        if ( !_relativeScale.hasPositiveWidth() || !_relativeScale.hasPositiveHeight() )
            return original;
        try {
            int width  = (int) (original.getIconWidth()  * scale * relativeScale.width().orElse(0.0f));
            int height = (int) (original.getIconHeight() * scale * relativeScale.height().orElse(0.0f));
            Image originalImage = original.getImage();
            if ( width == original.getIconWidth() && height == original.getIconHeight() )
                return original;
            if ( width <= 0 || height <= 0 ) {
                // We create the smallest possible image to avoid exceptions.
                return new ImageIcon(new ImageIcon(new byte[0]).getImage());
            }
            return new ImageIcon(originalImage.getScaledInstance(width, height, java.awt.Image.SCALE_SMOOTH));
        } catch ( Exception e ) {
            log.error("An error occurred while scaling an image icon.", e);
            return original;
        }
    }

    private void _updateScale() {
        float newScale = UI.scale();
        if ( newScale != _currentScale ) {
            _scaled = _scaleTo(newScale, _relativeScale, _sourceIcon);
            _currentScale = newScale;
        }
    }

    /**
     *  Returns a new {@link ScalableImageIcon} that will render the image
     *  at the given size.<br>
     *  <p>
     *      Note that the returned icon will be a new instance and will not
     *      affect the current icon.
     *  </p>
     * @param size The size to render the icon at.
     * @return A new {@link ScalableImageIcon} that will render the image
     *  at the given size.
     */
    public ScalableImageIcon withSize( Size size ) {
        return new ScalableImageIcon(size, _sourceIcon);
    }

    /**
     *  Exposes the width of the icon, or -1 if the icon does not have a fixed width.<br>
     *  <b>
     *      Note that the returned width is dynamically scaled according to
     *      the current {@link swingtree.UI#scale()} value.
     *      This is to ensure that the icon is rendered at the correct size
     *      according to the current DPI settings.
     *      If you want the unscaled width, use {@link #getBaseWidth()}.
     *  </b>
     * @return The width of the icon, or -1 if the icon does not have a width.
     */
    @Override
    public int getIconWidth() {
        _updateScale();
        return _scaled.getIconWidth();
    }

    /**
     *  Exposes the height of the icon, or -1 if the icon does not have a fixed height.<br>
     *  <b>
     *      Note that the returned height is dynamically scaled according to
     *      the current {@link swingtree.UI#scale()} value.
     *      This is to ensure that the icon is rendered at the correct size
     *      according to the current DPI settings.
     *      If you want the unscaled height, use {@link #getBaseHeight()}.
     *  </b>
     * @return The height of the icon, or -1 if the icon does not have a height.
     */
    @Override
    public int getIconHeight() {
        _updateScale();
        return _scaled.getIconHeight();
    }

    /**
     *  Returns the unscaled width of the icon.
     *  This is the width of the icon as it was originally loaded
     *  and is not affected by the current {@link swingtree.UI#scale()} value.<br>
     *  <p>
     *      If you want a width that is more suited for rendering
     *      according to the current DPI settings, use {@link #getIconWidth()}.
     *  </p>
     *
     * @return The unscaled width of the icon.
     */
    public int getBaseWidth() {
        return _baseSize.width().map(Math::round).orElse(0);
    }

    /**
     *  Returns the unscaled height of the icon.
     *  This is the height of the icon as it was originally loaded
     *  and is not affected by the current {@link swingtree.UI#scale()} value.<br>
     *  <p>
     *      If you want a height that is more suited for rendering
     *      according to the current DPI settings, use {@link #getIconHeight()}.
     *  </p>
     *
     * @return The unscaled height of the icon.
     */
    public int getBaseHeight() {
        return _baseSize.height().map(Math::round).orElse(0);
    }

    @Override
    public synchronized void paintIcon(java.awt.Component c, java.awt.Graphics g, int x, int y) {
        _updateScale();
        _scaled.paintIcon(c, g, x, y);
    }

    @Override
    public Image getImage() {
        _updateScale();
        return _scaled.getImage();
    }

    @Override
    public void setImage( Image image ) {
        log.warn("Setting the image of a "+this.getClass().getSimpleName()+" is not supported.", new Throwable());
    }

    @Override
    public String toString() {
        return this.getClass().getSimpleName() + "[" +
                    "baseSize=" + _baseSize + ", " +
                    "displaySize=" + Size.of(getIconWidth(), getIconHeight()) + ", " +
                    "scale=" + _currentScale + ", " +
                    "sourceSize=" + Size.of(_sourceIcon.getIconWidth(), _sourceIcon.getIconHeight()) +
                "]";
    }

    @Override
    public boolean equals( Object obj ) {
        if ( obj == this )
            return true;
        if ( obj == null || obj.getClass() != this.getClass() )
            return false;
        ScalableImageIcon other = (ScalableImageIcon) obj;
        return _sourceIcon.equals(other._sourceIcon) &&
               _relativeScale.equals(other._relativeScale) &&
               _baseSize.equals(other._baseSize);
    }

    @Override
    public int hashCode() {
        return Objects.hash(_sourceIcon, _relativeScale, _baseSize);
    }

}