LayerCache.java

package swingtree.style;

import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import swingtree.UI;
import swingtree.layout.Size;

import javax.swing.ImageIcon;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.lang.ref.WeakReference;
import java.util.Map;
import java.util.Objects;
import java.util.WeakHashMap;
import java.util.function.BiConsumer;

/**
 *  A {@link BufferedImage} based cache for the rendering of a particular layer of a component's style. <br>
 *  So if the {@link LayerRenderConf} of a component changes, the cache is invalidated and the layer
 *  is rendered again. <br>
 *  This is made possible by the fact that the {@link LayerRenderConf} is deeply immutable and can be used
 *  as a key data structure for caching.
 */
final class LayerCache
{
    private static final Map<LayerRenderConf, CachedImage> _CACHE = new WeakHashMap<>();
    private static final Logger log = LoggerFactory.getLogger(LayerCache.class);


    private static final class CachedImage extends BufferedImage
    {
        private WeakReference<LayerRenderConf> _key;
        private boolean _isRendered = false;

        CachedImage( int width, int height, LayerRenderConf cacheKey ) {
            super(width, height, BufferedImage.TYPE_INT_ARGB);
            _key = new WeakReference<>(cacheKey);
        }

        @Override
        public Graphics2D createGraphics() {
            if ( _isRendered )
                throw new IllegalStateException("This image has already been rendered into!");
            _isRendered = true;
            return super.createGraphics();
        }

        public LayerRenderConf getKeyOrElse( LayerRenderConf newFallbackKey ) {
            LayerRenderConf key = _key.get();
            if ( key == null ) {
                _key = new WeakReference<>(newFallbackKey);
                key = newFallbackKey;
            }
            return key;
        }

        public boolean isRendered() {
            return _isRendered;
        }
    }


    private final UI.Layer        _layer;
    private @Nullable CachedImage _localCache;
    private LayerRenderConf       _layerRenderData; // The key must be referenced strongly so that the value is not garbage collected (the cached image)
    private boolean               _cachingMakesSense = false;
    private boolean               _isInitialized     = false;


    public LayerCache( UI.Layer layer ) {
        _layer           = Objects.requireNonNull(layer);
        _layerRenderData = LayerRenderConf.none();
    }

    LayerRenderConf getCurrentRenderInputData() {
        return _layerRenderData;
    }

    public boolean hasBufferedImage() {
        return _localCache != null;
    }

    private void _allocateOrGetCachedBuffer( LayerRenderConf layerRenderConf)
    {
        Map<LayerRenderConf, CachedImage> CACHE = _CACHE;

        CachedImage bufferedImage = CACHE.get(layerRenderConf);

        if ( bufferedImage == null ) {
            Size size = layerRenderConf.boxModel().size();
            bufferedImage = new CachedImage(
                                size.width().map(Number::intValue).orElse(1),
                                size.height().map(Number::intValue).orElse(1),
                                layerRenderConf
                            );
            CACHE.put(layerRenderConf, bufferedImage);

            _layerRenderData = layerRenderConf;
        }
        else {
            // We keep a strong reference to the state so that the cached image is not garbage collected
            _layerRenderData = bufferedImage.getKeyOrElse(layerRenderConf);
            /*
                The reason why we take the key stored in the cached image as a strong reference is because this
                key object is also the key in the global (weak) hash map based cache
                whose reachability determines if the cached image is garbage collected or not!
                So in order to avoid the cache being freed too early, we need to keep a strong
                reference to the key object for all LayerCache instances that make use of the
                corresponding cached image (the value of a particular key in the global cache).
            */
        }

        _localCache = bufferedImage;
    }

    private void _freeLocalCache() {
        _localCache        = null;
        _cachingMakesSense = false;
        _isInitialized     = false;
    }

    public final void validate( ComponentConf oldConf, ComponentConf newConf )
    {
        if ( newConf.currentBounds().hasWidth(0) || newConf.currentBounds().hasHeight(0) ) {
            _layerRenderData = LayerRenderConf.none();
            return;
        }

        final LayerRenderConf oldState = oldConf.toRenderConfFor(_layer);
        final LayerRenderConf newState = newConf.toRenderConfFor(_layer);

        boolean validationNeeded = ( !_isInitialized || !oldState.equals(newState) );

        _isInitialized = true;

        if ( validationNeeded )
            _cachingMakesSense = _cachingMakesSenseFor(newState);

        if ( !_cachingMakesSense ) {
            _freeLocalCache();
            _layerRenderData = newState;
            return;
        }

        boolean cacheIsInvalid = true;
        boolean cacheIsFull    = _CACHE.size() > 128;

        boolean newBufferNeeded = false;

        if ( _localCache == null )
            newBufferNeeded = true;
        else
            cacheIsInvalid = !oldState.equals(newState);

        if ( cacheIsInvalid ) {
            _freeLocalCache();
            newBufferNeeded = true;
        }

        if ( cacheIsFull ) {
            _layerRenderData = newState;
            return;
        }

        if ( newBufferNeeded )
            _allocateOrGetCachedBuffer(newState);
    }

    public final void paint( Graphics2D g, BiConsumer<LayerRenderConf, Graphics2D> renderer )
    {
        Size size = _layerRenderData.boxModel().size();

        if ( size.width().orElse(0f) == 0f || size.height().orElse(0f) == 0f )
            return;

        if ( !_cachingMakesSense ) {
            renderer.accept(_layerRenderData, g);
            return;
        }

        if ( _localCache == null )
            return;

        if ( !_localCache.isRendered() ) {
            Graphics2D g2 = _localCache.createGraphics();
            try {
                StyleUtil.transferConfigurations(g, g2);
            }
            catch ( Exception ignored ) {
                log.debug("Error while transferring configurations to the cached image graphics context.");
            }
            finally {
                renderer.accept(_layerRenderData, g2);
                g2.dispose();
            }
        }

        g.drawImage(_localCache, 0, 0, null);
    }

    public boolean _cachingMakesSenseFor( LayerRenderConf state )
    {
        final Size size = state.boxModel().size();

        if ( !size.hasPositiveWidth() || !size.hasPositiveHeight() )
            return false;

        if ( state.layer().hasPaintersWhichCannotBeCached() )
            return false; // We don't know what the painters will do, so we don't cache their painting!

        int heavyStyleCount = 0;

        for ( ImageConf imageConf : state.layer().images().sortedByNames() )
            if ( !imageConf.equals(ImageConf.none()) && imageConf.image().isPresent() ) {
                ImageIcon icon = imageConf.image().get();
                boolean isSpecialIcon = ( icon.getClass() != ImageIcon.class );
                boolean hasSize = ( icon.getIconHeight() > 0 || icon.getIconWidth() > 0 );
                if ( isSpecialIcon || hasSize )
                    heavyStyleCount++;
            }
        for ( GradientConf gradient : state.layer().gradients().sortedByNames() )
            if ( !gradient.equals(GradientConf.none()) && gradient.colors().length > 0 )
                heavyStyleCount++;
        for ( NoiseConf noise : state.layer().noises().sortedByNames() )
            if ( !noise.equals(NoiseConf.none()) && noise.colors().length > 0 )
                heavyStyleCount++;
        for ( TextConf text : state.layer().texts().sortedByNames() )
            if ( !text.equals(TextConf.none()) && !text.content().isEmpty() )
                heavyStyleCount++;
        for ( ShadowConf shadow : state.layer().shadows().sortedByNames() )
            if ( !shadow.equals(ShadowConf.none()) && shadow.color().isPresent() )
                heavyStyleCount++;

        final BaseColorConf baseCoors = state.baseColors();
        final BoxModelConf  boxModel  = state.boxModel();
        final boolean       isRounded = boxModel.hasAnyNonZeroArcs();

        if ( _layer == UI.Layer.BORDER ) {
            boolean hasWidth = !Outline.none().equals(boxModel.widths());
            boolean hasColoring = !baseCoors.borderColor().equals(BorderColorsConf.none());
            if ( hasWidth && hasColoring )
                heavyStyleCount++;
        }
        if ( _layer == UI.Layer.BACKGROUND ) {
            boolean roundedOrHasMargin = isRounded || !boxModel.margin().equals(Outline.none());
            if ( roundedOrHasMargin ) {
                if ( baseCoors.backgroundColor().filter( c -> c.getAlpha() > 0 ).isPresent() )
                    heavyStyleCount++;
                if ( baseCoors.foundationColor().filter( c -> c.getAlpha() > 0 ).isPresent() )
                    heavyStyleCount++;
            }
        }

        if ( heavyStyleCount < 1 )
            return false;

        final int threshold = 256 * 256 * Math.min(heavyStyleCount, 5);
        final int pixelCount = (int) (size.width().orElse(0f) * size.height().orElse(0f));

        return pixelCount <= threshold;
    }

}