LayerPartitionCache.java

package swingtree.style;

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

import javax.swing.*;
import java.awt.*;
import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage;
import java.awt.image.ColorModel;
import java.awt.image.DataBuffer;
import java.awt.image.DirectColorModel;
import java.awt.image.Raster;
import java.util.Map;
import java.util.Objects;
import java.util.WeakHashMap;
import java.util.function.BiConsumer;

/**
 *  This class manages the cached rendering of one piece of a component's style: usually a whole
 *  layer ({@link LayerRenderConfPartition#WHOLE}), and otherwise one part of a layer that could
 *  not be drawn into a single image. <br>
 *  The cache key is the deeply immutable {@link LayerRenderConf} describing that piece, so a
 *  paint whose description is unchanged blits the image we already have, and a paint whose
 *  description changed drops the entry and draws again. There is one instance of this per
 *  component, layer and part, but the images themselves live in a global pool keyed weakly by
 *  the description, so every component with an equal style shares one image. <br>
 *  <br>
 *  <b>Size independent caching through stretch tiling ("nine slice"):</b><br>
 *  The render configuration includes the exact component {@link Size}, so naively every frame of
 *  a live resize would be a cache miss and heavily styled components would be rasterized from
 *  scratch dozens of times per second. For eligible styles this cache therefore keys its entry
 *  on a <b>minimal exemplar</b> of the style instead: the same configuration with its size
 *  replaced by the smallest size at which every size dependent pixel still exists. Because
 *  {@link Size} is the <i>only</i> size dependent property in the whole configuration, the
 *  exemplar works as a size independent cache key, mapping all sizes of a style onto one value,
 *  and at the same time as a valid render instruction: {@link StyleRenderer} receives exactly
 *  what a real component of that small size would send, so it needs no special case for tiling.
 *  The exemplar rendering then acts like a small texture atlas from which the actual rendering
 *  is reconstructed on the fly using nine tile blits: the four corners copied 1:1, the four edge
 *  bands and the center stretched - the technique behind Android 9-patch drawables and CSS
 *  {@code border-image}. <br>
 *  <br>
 *  This gives back exactly the same pixels, but only for styles that meet one condition: <b>the
 *  parts we stretch have to look the same all the way along the direction we stretch them</b>. Flat
 *  background/foundation fills, borders and shadows satisfy it. Noises, images, texts and custom
 *  painters do not, their pixels depending on the full component bounds. A border with a
 *  different color per edge satisfies it as long as its miter joints land in the same place at
 *  every size. <br>
 *  <br>
 *  There are two directions we can stretch, so that is two separate conditions, and a style can
 *  pass one and fail the other. A gradient running top to bottom paints every
 *  pixel strip along the y axis the same, so its width can be compacted while its height stays
 *  exactly the component's. {@link LayerRenderConf.Compaction} says which of the two dimensions we
 *  compacted. Resizing the component in an uncompacted dimension gives a new key; resizing it
 *  in a compacted dimension does not. <br>
 *  <br>
 *  Two places in this class use that. The first decides whether to allocate an image buffer
 *  while a component resizes: we do that only for a key with a compacted dimension, and only
 *  when its uncompacted dimensions still hold last paint's values, because a buffer drawn for a
 *  key that the next paint replaces is blitted once and then never asked for again. The second
 *  is {@link #wouldCompactADimension}, which {@link StyleLayerCache} calls to decide whether
 *  cutting a layer around its noise is worth a second cache entry; one compacted dimension
 *  already pays for one, so it asks for no more than that.
 *  The eligibility check must stay conservative, because an over-eager rule produces subtly wrong
 *  pixels, not a crash.
 *  A style we turn down is keyed on its real size and blitted one to one, with no stretching
 *  involved. A component the exemplar already fills in one dimension is keyed on its own
 *  measurement in that dimension, and the other dimension can still be compacted, which is what
 *  lets a wide, short bar be cached at all. <br>
 *  <br>
 *  So two configurations are in play at once, and most of the code below only makes sense if
 *  you keep them apart:
 *  <ul>
 *      <li><b>the render input</b> ({@code _renderInput}) - always the actual configuration
 *          at the real component size. All direct-render fallbacks receive it, and it determines
 *          the destination geometry of the final cache blit.</li>
 *      <li><b>the cache key</b> ({@code Cached._key}) - the canonical (possibly exemplar sized)
 *          form of the render input. It keys the entry in the global cache, it is what the
 *          renderer receives when filling the shared image, and its strong reference is what
 *          keeps the weakly keyed entry alive.</li>
 *  </ul>
 */
final class LayerPartitionCache
{
    private static final Logger log = LoggerFactory.getLogger(LayerPartitionCache.class);
    enum PaintOutcome {
        NOTHING_RENDERED,
        RENDERED_FROM_CACHE,
        RENDERED_FROM_STYLE
    }

    private static final Map<Pooled<LayerRenderConf>, CachedImage> _CACHE = new WeakHashMap<>();

    private static final int    MAX_CACHE_ENTRIES                 = 1024; // There can never be more entries!
    private static final int    PIXELS_PER_UNIT_OF_AGGRESSIVENESS = 256 * 256; // Determines how many pixels a single unit of cache aggressiveness can cache
    private static final double EAGER_ALLOCATION_FRIENDLINESS     = 0.1; // Has to be between 0 and 1!
    private static final int    MAX_CACHE_HIT_COUNT               = 12;
    private static final int    HITS_UNTIL_ALLOCATION_WHILE_RESIZING = 1;
    private static final int    BYTES_PER_PIXEL                   = 4; // Every cached rendering is 32 bit ARGB, see CachedImage._allocate.

    private static int  _maxCacheableImageArea() { return (int) (CacheBudget.units() * PIXELS_PER_UNIT_OF_AGGRESSIVENESS); }
    private static int  _maxCacheEntries()       { return Math.min(MAX_CACHE_ENTRIES, CacheBudget.maxEntriesFor(CacheBudget.Kind.STYLE_LAYER)); }

    static int globalEntryCount() {
        return _CACHE.size();
    }

    static long globalBytesReserved() {
        long total = 0;
        for ( CachedImage image : _CACHE.values() )
            total += image.reservedBytes();
        return total;
    }

    static void clearGlobalCache() {
        _CACHE.clear();
    }

    // Sum type based states
    private interface CacheState {
        final class Nothing  implements CacheState { static final Nothing  INSTANCE = new Nothing();  }
        final class Rejected implements CacheState { static final Rejected INSTANCE = new Rejected(); }
        final class Cached   implements CacheState {
            final Pooled<LayerRenderConf> _key;
            final CachedImage             _image;
            Cached( Pooled<LayerRenderConf> key, CachedImage image ) {
                _key   = key;
                _image = image;
            }
        }
    }

    private final UI.Layer                 _layer;
    private final LayerRenderConfPartition _part;
    private LayerRenderConf                _renderInput;
    private CacheState                     _state;


    public LayerPartitionCache( UI.Layer layer, LayerRenderConfPartition part ) {
        _layer       = Objects.requireNonNull(layer);
        _part        = Objects.requireNonNull(part);
        _renderInput = LayerRenderConf.none();
        _state       = CacheState.Nothing.INSTANCE;
    }

    /** A part has no admission decisions left open if it is either cached, or there
     *  is nothing to cache. But a part that was refused admission may be admitted later, because
     *  that decision also depends on how full the global cache is, so it has to be re-validated
     *  and offered again on the next paint. */
    boolean admissionDecisionLeftOpen() {
        return _state instanceof CacheState.Rejected;
    }

    public @Nullable BufferedImage renderedImage() {
        if ( !(_state instanceof CacheState.Cached) )
            return null;
        final CachedImage image = ((CacheState.Cached) _state)._image;
        return image.isRendered() ? image.getImage() : null;
    }

    public void validate( ComponentConf newConf, boolean isResizing )
    {
        if ( newConf.currentBounds().hasWidth(0) || newConf.currentBounds().hasHeight(0) ) {
            _renderInput = LayerRenderConf.none();
            _state       = CacheState.Nothing.INSTANCE;
            return;
        }

        final LayerRenderConf previousInput = _renderInput;
        _renderInput = _part.restrict(newConf.renderConfFor(_layer));
        if ( _renderInput.rendersNothing() ) {
            _state = CacheState.Nothing.INSTANCE;
            return;
        }

        final LayerRenderConf keyConf = CacheBudget.tilingEnabled()
                                            ? _renderInput.canonicalRepresentation()
                                            : _renderInput;

        if ( _state instanceof CacheState.Cached && ((CacheState.Cached) _state)._key.get().equals(keyConf) )
            return;

        final int hitsUntilAllocation = _hitsUntilAllocationFor(keyConf, _renderInput, previousInput, isResizing);
        if ( hitsUntilAllocation < 0 ) {
            _state = CacheState.Rejected.INSTANCE; // The cache refused admission!
        } else {
            final Pooled<LayerRenderConf> key = new Pooled<>(keyConf).intern();

            CachedImage image = _CACHE.get(key);
            if (image == null) {
                image = new CachedImage(keyConf.boxModel().size(), hitsUntilAllocation);
                _CACHE.put(key, image);
            }
            _state = new CacheState.Cached(key, image);
        }
    }

    PaintOutcome paint( Graphics2D g, BiConsumer<LayerRenderConf, Graphics2D> renderer )
    {
        final Size size = _renderInput.boxModel().size();

        if ( _state instanceof CacheState.Nothing )
            return PaintOutcome.NOTHING_RENDERED;

        if ( size.widthOrElse(0f) == 0f || size.heightOrElse(0f) == 0f )
            return PaintOutcome.NOTHING_RENDERED;

        if ( !(_state instanceof CacheState.Cached) ) {
            renderer.accept(_renderInput, g);
            return PaintOutcome.RENDERED_FROM_STYLE;
        }

        final CacheState.Cached cached = (CacheState.Cached) _state;
        final CachedImage image        = cached._image;
        final LayerRenderConf cacheKey = cached._key.get();
        /*
            A cache key size differing from the actual size means the entry is the small
            exemplar rendering, and painting means reconstructing the actual size from it
            through nine tile blits. That is only possible for plain scaling transforms;
            under anything more exotic (rotation, shear, flips) we render directly
            instead of using the cache for this paint.
        */
        final boolean isTiled = !cacheKey.boxModel().size().equals(size);
        if ( isTiled && !_isBlitCompatible(g.getTransform()) ) {
            renderer.accept(_renderInput, g);
            return PaintOutcome.RENDERED_FROM_STYLE;
        }

        final PaintOutcome outcome;
        if ( !image.isRendered() ) {
            Graphics2D g2 = image.createGraphics(g.getDeviceConfiguration());
            if ( g2 == null ) {
                /*
                    The cache is not yet ready to render into!
                    It will need a few more hits to be ready...
                    So we just do normal rendering instead:
                */
                renderer.accept(_renderInput, g);
                return PaintOutcome.RENDERED_FROM_STYLE;
            }
            try {
                StyleUtil.transferConfigurations(g, g2);
            }
            catch ( Exception ignored ) {
                log.debug(SwingTree.get().logMarker(), "Error while transferring configurations to the cached image graphics context.");
            }
            finally {
                /*
                    Note the deliberate asymmetry: the shared image is filled by rendering
                    the *cache key* configuration (possibly the small exemplar), whereas
                    the direct-render fallbacks above render the full sized render input.
                */
                renderer.accept(cacheKey, g2);
                g2.dispose();
            }
            outcome = PaintOutcome.RENDERED_FROM_STYLE;
        } else {
            outcome = PaintOutcome.RENDERED_FROM_CACHE;
        }

        final BufferedImage cachedImage = image.getImage();
        if ( cachedImage == null )
            return outcome; // Cannot happen (the count-down path returned above), but let's be defensive.

        if ( isTiled )
            image.paintStretched(g, cacheKey, size);
        else
            g.drawImage(cachedImage, 0, 0, null);

        return outcome;
    }

    private int _hitsUntilAllocationFor(
        LayerRenderConf cacheKey, LayerRenderConf renderInput, LayerRenderConf previousInput, boolean isResizing
    ) {
        if ( _isWorthAllocatingRightAway(cacheKey, renderInput, previousInput, isResizing) )
            return _cachingMakesSenseFor(_layer, cacheKey);

        if ( _isTooLargeToAllocateWhileResizing(cacheKey) )
            return _hitsForReusingAFinishedRendering(cacheKey);

        final int hits = _cachingMakesSenseFor(_layer, cacheKey);
        if ( hits < 0 || _isTrivialToAllocateWhileResizing(cacheKey) )
            return hits;
        return Math.max(HITS_UNTIL_ALLOCATION_WHILE_RESIZING, hits);
    }

    /**
     *  Whether this key's image buffer may be allocated on the spot, rather than only once a
     *  second user has asked for it. The entry itself is created either way; only the buffer
     *  waits. Naively, we would allocate a buffer as soon as a key appears. While a component
     *  is resizing, however, it changes size between paints, so the buffer we just filled is
     *  blitted once and then never asked for again. To avoid that, we only allocate when the
     *  resize cannot replace the key: the key is replaced only if the component changed size in
     *  an uncompacted dimension, so we compare the component's size at this paint against its
     *  size at the previous paint, in exactly those dimensions.
     */
    private static boolean _isWorthAllocatingRightAway(
        LayerRenderConf cacheKey, LayerRenderConf renderInput, LayerRenderConf previousInput, boolean isResizing
    ) {
        if ( !isResizing )
            return true;

        final Size actual = renderInput.boxModel().size();
        final LayerRenderConf.Compaction compacted =
                    LayerRenderConf.Compaction.between(cacheKey.boxModel().size(), actual);

        if ( compacted == LayerRenderConf.Compaction.NONE )
            return false; // An exact-size key is invalidated by any frame of any drag.

        final Size previous = previousInput.boxModel().size();
        return ( compacted.includesWidth()  || previous.widthOrElse(0f)  == actual.widthOrElse(0f)  )
            && ( compacted.includesHeight() || previous.heightOrElse(0f) == actual.heightOrElse(0f) );
    }

    private static boolean _isTooLargeToAllocateWhileResizing( LayerRenderConf cacheKey ) {
        final Size size = cacheKey.boxModel().size();
        return size.widthOrElse(0f) * size.heightOrElse(0f) > _eagerAllocationLimit();
    }

    private static boolean _isTrivialToAllocateWhileResizing( LayerRenderConf cacheKey ) {
        final Size size = cacheKey.boxModel().size();
        final int trivialAllocationLimit = (int) ( _eagerAllocationLimit() * EAGER_ALLOCATION_FRIENDLINESS );
        return size.widthOrElse(0f) * size.heightOrElse(0f) <= trivialAllocationLimit;
    }

    private static int _hitsForReusingAFinishedRendering( LayerRenderConf cacheKey ) {
        /*
            Deliberately not interned: `Pooled` compares by value, so a plain probe finds the
            entry without putting anything into the object pool for a key we may not use.
        */
        final @Nullable CachedImage existing = _CACHE.get(new Pooled<>(cacheKey));
        return ( existing != null && existing.isRendered() ? 0 : -1 );
    }

    /** The image area up to which an entry is worth allocating right away rather than after a
     *  warm-up of cache hits; also the line above which allocating one mid-resize is a loss. */
    private static int _eagerAllocationLimit() {
        return (int) ( _maxCacheableImageArea() * EAGER_ALLOCATION_FRIENDLINESS );
    }

    static boolean wouldBeAdmitted( UI.Layer layer, LayerRenderConf conf ) {
        return _cachingMakesSenseFor(layer, conf) >= 0;
    }

    private static int _cachingMakesSenseFor( UI.Layer layer, LayerRenderConf conf )
    {
        final int maxEntries = _maxCacheEntries();
        if ( maxEntries <= 0 || _CACHE.size() >= maxEntries )
            return -1; // Caching disabled or cache already too full, don't admit more entries.

        final Size size = conf.boxModel().size();

        if ( !size.hasPositiveWidth() || !size.hasPositiveHeight() )
            return -1; // The component does not have a size that can be displayed.

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

        int heavyStyleCount = 0;

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

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

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

        if ( heavyStyleCount < 1 )
            return -1;

        final int maxSizeLimit         = _maxCacheableImageArea();
        final int eagerAllocationLimit = _eagerAllocationLimit();
        final int cacheHitCountLimit   = (int) (maxSizeLimit * (1 - EAGER_ALLOCATION_FRIENDLINESS));

        final int pixelCount = (int) (size.widthOrElse(0f) * size.heightOrElse(0f));
        final int score      = pixelCount / Math.min(heavyStyleCount, 5); // Heavier styles get cached more easily!

        if ( score > maxSizeLimit )
            return -1; // We are not going to cache such a large image!
        else if ( score <= eagerAllocationLimit )
            return 0; // Nice and small, definitely worth allocating and caching right away!
        else
            return 1 + (score - eagerAllocationLimit) / Math.max(1, cacheHitCountLimit / MAX_CACHE_HIT_COUNT);
            // Here we return the number of cache hits until allocation and rendering should happen.
    }

    /*  ------------------------------------------------------------------------------------
        Stretch tiling geometry - pure functions deriving the size independent cache key
        and the slice cut lines from a render configuration (see class javadoc).
        ------------------------------------------------------------------------------------ */

    /**
     *  Whether the cache would compact a dimension of this configuration, so that a resize
     *  changing only that dimension is served from the entry rather than rendered again.
     *  Deliberately weaker than {@link #_isWorthAllocatingRightAway}, which also asks that the
     *  component's uncompacted dimensions still hold last paint's values.
     */
    static boolean wouldCompactADimension( LayerRenderConf conf ) {
        if ( !CacheBudget.tilingEnabled() )
            return false;
        final Size key    = conf.canonicalRepresentation().boxModel().size();
        final Size actual = conf.boxModel().size();
        return LayerRenderConf.Compaction.between(key, actual) != LayerRenderConf.Compaction.NONE;
    }

    /** Tile blits support positive scaling and translation; rotation, shear and flips do not. */
    private static boolean _isBlitCompatible( AffineTransform transform ) {
        return transform.getShearX() == 0 &&
               transform.getShearY() == 0 &&
               transform.getScaleX() >  0 &&
               transform.getScaleY() >  0;
    }

    /**
     *  A wrapper for a cached image that is either rendered or not yet allocated and
     *  associated with a particular {@link LayerRenderConf} key, which is used
     *  by the {@link LayerPartitionCache} instance of a particular component to get a strong
     *  reference to the key (causing it to stay in cache and not get garbage collected). <br>
     *  <br>
     *  So instances of this are stored as values in the global {@link #_CACHE},
     *  and can be accessed and shared by multiple {@link LayerPartitionCache} instances.
     *  (So be careful with modifying this class!)<br>
     *  The image can be allocated lazily only after a certain number of cache
     *  hits have been reached. This is to avoid allocating and rendering cache
     *  data for short-lived paint jobs (like animations for example). <br>
     *  <br>
     *  When the image is an exemplar rendering, this class also owns its reconstruction:
     *  {@link #paintStretched} reassembles any actual component size from the image
     *  through nine tile blits.
     */
    private static final class CachedImage
    {
        /** Indices into the {@link #_stretchTiles} array. */
        private static final int TOP = 0, LEFT = 1, CENTER = 2, RIGHT = 3, BOTTOM = 4;
        private interface StretchTile {
            final class Nothing implements StretchTile {
                static final Nothing INSTANCE = new Nothing();
            }
            final class FillColor implements StretchTile {
                final Color _color;
                FillColor( Color color ) { _color = color; }
            }
            final class Image implements StretchTile {
                final BufferedImage _image;
                Image( BufferedImage image ) { _image = image; }
            }
        }

        private final int                      _width;
        private final int                      _height;
        private @Nullable BufferedImage        _image;
        private StretchTile @Nullable []       _stretchTiles;
        private boolean                        _isRendered;
        private int                            _numberOfHitsUntilAllocation;


        CachedImage( Size size, int numberOfHitsUntilAllocation ) {
            _isRendered                  = false;
            _width                       = Math.max(1, size.width().map(Number::intValue).orElse(1));
            _height                      = Math.max(1, size.height().map(Number::intValue).orElse(1));
            _image                       = null;
            _numberOfHitsUntilAllocation = numberOfHitsUntilAllocation;
        }

        /** The memory this entry has claimed: its image - counted from the moment the entry
         *  exists, not from the moment the buffer is actually allocated.  */
        long reservedBytes() {
            long total = (long) _width * _height * BYTES_PER_PIXEL;
            if ( _stretchTiles != null )
                for ( StretchTile tile : _stretchTiles )
                    if ( tile instanceof StretchTile.Image )
                        total += _bytesOf(((StretchTile.Image) tile)._image);
            return total;
        }

        private static long _bytesOf( BufferedImage image ) {
            return (long) image.getWidth() * image.getHeight() * BYTES_PER_PIXEL;
        }

        private static BufferedImage _allocate( @Nullable GraphicsConfiguration gc, int width, int height ) {
            BufferedImage img = ( gc != null )
                    ? gc.createCompatibleImage(width, height, Transparency.TRANSLUCENT) // potentially accelerated
                    : new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); // probably headless
            img.setAccelerationPriority(1.0f);
            return img;
        }

        public @Nullable BufferedImage getImage() {
            return _image;
        }

        /**
         *  Creates a {@link Graphics2D} for rendering into the cached image, or returns
         *  null while the hits-until-allocation count-down (which this call decrements)
         *  has not reached zero yet. The image is allocated on the first call after that.
         */
        public @Nullable Graphics2D createGraphics( @Nullable GraphicsConfiguration gc ) {
            if ( _isRendered )
                throw new IllegalStateException("This image has already been rendered into!");
            if ( _numberOfHitsUntilAllocation > 0 ) {
                _numberOfHitsUntilAllocation--;
                return null;
            }
            if ( _image == null )
                _image = _allocate(gc, _width, _height);
            _isRendered = true;
            return _image.createGraphics();
        }

        public boolean isRendered() {
            return _isRendered;
        }

        /**
         *  Reconstructs the rendering of this (exemplar) image at the supplied actual
         *  component size by drawing nine tiles: the four corners 1:1 straight from the
         *  image, the four edge bands stretched along their edge and the center stretched
         *  in both directions - the latter five from their dedicated tile images. <br>
         *  <br>
         *  Cutting a dimension is what makes it stretchable, so a dimension this image already
         *  carries at the component's own measurement is not cut: an image compacted in one
         *  dimension only is drawn as three tiles rather than nine. <br>
         *  <br>
         *  The tiles are drawn in <b>integer device space</b>: the cut lines are transformed
         *  to device pixels once and shared between adjacent tiles, so that under fractional
         *  HiDPI scales the independent rounding of nine user space rectangles can never
         *  produce one pixel gaps or double blended overlaps. Nearest neighbor interpolation
         *  ensures that stretching a constant source band produces an exactly constant
         *  destination band and that sampling never bleeds across tile boundaries. <br>
         *  Note: Antialiasing is switched off for the whole reconstruction so that solid FillColor tiles can be drawn efficiently. <br>
         *
         * @param g The destination graphics to draw the tiles into.
         * @param canonicalConf The exemplar configuration this image was rendered from,
         *                      used to recompute the slice insets.
         * @param actualSize The actual component size to reconstruct.
         */
        public void paintStretched( Graphics2D g, LayerRenderConf canonicalConf, Size actualSize )
        {
            final BufferedImage image = _image;
            if ( image == null )
                return; // Cannot happen (callers check `isRendered()` first), but let's be defensive.

            final Outline insets = canonicalConf.nineTileSliceInsets();
            final LayerRenderConf.Compaction compaction =
                        LayerRenderConf.Compaction.between(canonicalConf.boxModel().size(), actualSize);
            final int insetTop    = compaction.includesHeight() ? insets.top().orElse(0f).intValue()    : 0;
            final int insetRight  = compaction.includesWidth()  ? insets.right().orElse(0f).intValue()  : 0;
            final int insetBottom = compaction.includesHeight() ? insets.bottom().orElse(0f).intValue() : 0;
            final int insetLeft   = compaction.includesWidth()  ? insets.left().orElse(0f).intValue()   : 0;

            StretchTile[] tiles = _stretchTiles;
            if ( tiles == null ) {
                tiles = _extractStretchTiles(g.getDeviceConfiguration(), image, insetTop, insetRight, insetBottom, insetLeft);
                _stretchTiles = tiles;
            }

            final float actualWidth  = actualSize.widthOrElse(0f);
            final float actualHeight = actualSize.heightOrElse(0f);

            final AffineTransform transform = g.getTransform();
            final double scaleX     = transform.getScaleX();
            final double scaleY     = transform.getScaleY();
            final double translateX = transform.getTranslateX();
            final double translateY = transform.getTranslateY();

            // The horizontal and vertical cut lines in integer device space,
            // shared between adjacent tiles (no seams, no overlaps):
            final int[] dx = {
                            (int) Math.round(translateX),
                            (int) Math.round(translateX + insetLeft * scaleX),
                            (int) Math.round(translateX + (actualWidth - insetRight) * scaleX),
                            (int) Math.round(translateX + actualWidth * scaleX)
                        };
            final int[] dy = {
                            (int) Math.round(translateY),
                            (int) Math.round(translateY + insetTop * scaleY),
                            (int) Math.round(translateY + (actualHeight - insetBottom) * scaleY),
                            (int) Math.round(translateY + actualHeight * scaleY)
                        };
            // The corresponding cut lines in the exemplar source image:
            final int[] sx = { 0, insetLeft, _width  - insetRight,  _width  };
            final int[] sy = { 0, insetTop,  _height - insetBottom, _height };

            final Graphics2D g2 = (Graphics2D) g.create();
            try {
                g2.setTransform(new AffineTransform()); // We draw in device space.
                g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR);
                g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF);
                // The four corners, 1:1 sub-rectangle copies from the exemplar image:
                _drawRegion(g2, image, dx[0], dy[0], dx[1], dy[1], sx[0], sy[0], sx[1], sy[1]); // top-left
                _drawRegion(g2, image, dx[2], dy[0], dx[3], dy[1], sx[2], sy[0], sx[3], sy[1]); // top-right
                _drawRegion(g2, image, dx[0], dy[2], dx[1], dy[3], sx[0], sy[2], sx[1], sy[3]); // bottom-left
                _drawRegion(g2, image, dx[2], dy[2], dx[3], dy[3], sx[2], sy[2], sx[3], sy[3]); // bottom-right
                // The stretched bands and center, each a whole dedicated image:
                _drawStretched(g2, tiles[TOP],    dx[1], dy[0], dx[2], dy[1]);
                _drawStretched(g2, tiles[LEFT],   dx[0], dy[1], dx[1], dy[2]);
                _drawStretched(g2, tiles[CENTER], dx[1], dy[1], dx[2], dy[2]);
                _drawStretched(g2, tiles[RIGHT],  dx[2], dy[1], dx[3], dy[2]);
                _drawStretched(g2, tiles[BOTTOM], dx[1], dy[2], dx[2], dy[3]);
            } finally {
                g2.dispose();
            }
        }

        /**
         *  Copies the five stretchable regions (four edge bands + center) into dedicated,
         *  exactly-fitting images. <br>
         *  <br>
         *  <b>Why:</b> the stretched tiles could in principle be drawn straight out of the
         *  exemplar image with sub-rectangle {@code drawImage} calls - and on software
         *  surfaces that is pixel perfect. But on accelerated pipelines (notably XRender on
         *  Linux) a scaled blit whose source is an <i>interior sub-rectangle</i> of a larger
         *  texture breaks down at large stretch ratios: beyond a few hundred times, the blit
         *  samples outside the source band or produces nothing at all, which visually
         *  manifested as long component edges losing their shadows. A scaled blit whose
         *  source is a <i>whole image</i> measures pixel perfect even at extreme ratios -
         *  so each stretched tile gets its own image, while the corner tiles (copied 1:1,
         *  never stretched) keep sourcing the exemplar image directly. <br>
         *  <br>
         *  A region that turns out to be empty or single colored needs no image and no blit
         *  at all - see {@link StretchTile} for what those cases are and what is done with
         *  them instead.
         */
        private static StretchTile[] _extractStretchTiles(
            final @Nullable GraphicsConfiguration gc,
            final BufferedImage image,
            final int insetTop, final int insetRight, final int insetBottom, final int insetLeft
        ) {
            final int width  = image.getWidth();
            final int height = image.getHeight();
            final StretchTile[] tiles = new StretchTile[5];
            tiles[TOP]    = _extractTile(gc, image, insetLeft,          0,                    width - insetRight, insetTop           );
            tiles[LEFT]   = _extractTile(gc, image, 0,                  insetTop,             insetLeft,          height - insetBottom);
            tiles[CENTER] = _extractTile(gc, image, insetLeft,          insetTop,             width - insetRight, height - insetBottom);
            tiles[RIGHT]  = _extractTile(gc, image, width - insetRight, insetTop,             width,              height - insetBottom);
            tiles[BOTTOM] = _extractTile(gc, image, insetLeft,          height - insetBottom, width - insetRight, height             );
            return tiles;
        }

        private static StretchTile _extractTile(
            final @Nullable GraphicsConfiguration gc,
            final BufferedImage source,
            final int x1, final int y1, final int x2, final int y2
        ) {
            final @Nullable StretchTile bufferless = _scanForBufferlessTile(source, x1, y1, x2, y2);
            if ( bufferless != null )
                return bufferless;
            final int width  = Math.max(1, x2 - x1);
            final int height = Math.max(1, y2 - y1);
            final BufferedImage region = _allocate(gc, width, height);
            final Graphics2D g = region.createGraphics();
            try {
                g.setComposite(AlphaComposite.Src); // exact pixel copy, including alpha
                g.drawImage(source, 0, 0, width, height, x1, y1, x2, y2, null);
            } finally {
                g.dispose();
            }
            return new StretchTile.Image(region);
        }

        private static @Nullable StretchTile _scanForBufferlessTile(
            final BufferedImage image,
            final int x1, final int y1, final int x2, final int y2
        ) {
            final ColorModel colorModel = image.getColorModel();
            if ( !(colorModel instanceof DirectColorModel) || !colorModel.hasAlpha() )
                return null;
            final Raster raster = image.getRaster();
            if ( raster.getTransferType() != DataBuffer.TYPE_INT || raster.getNumDataElements() != 1 )
                return null;
            final int alphaMask = ((DirectColorModel) colorModel).getAlphaMask();
            final int startX = Math.max(0, x1);
            final int startY = Math.max(0, y1);
            final int width  = Math.min(image.getWidth(),  x2) - startX;
            final int endY   = Math.min(image.getHeight(), y2);
            if ( width <= 0 || endY <= startY )
                return StretchTile.Nothing.INSTANCE;

            final int[] row = new int[width];
            int     alphaBits = 0;
            int     firstPixel = 0;
            boolean isUniform  = true;
            for ( int y = startY; y < endY; y++ ) {
                raster.getDataElements(startX, y, width, 1, row);
                if ( y == startY )
                    firstPixel = row[0];
                for ( int i = 0; i < width; i++ ) {
                    final int pixel = row[i];
                    alphaBits |= pixel;
                    isUniform &= ( pixel == firstPixel );
                }
                if ( !isUniform && (alphaBits & alphaMask) != 0 )
                    return null; // Neither empty nor uniform, so an image it is.
            }
            if ( (alphaBits & alphaMask) == 0 )
                return StretchTile.Nothing.INSTANCE;
            if ( !isUniform )
                return null;
            final int argb = colorModel.getRGB(firstPixel);
            if ( (argb >>> 24) != 0xFF )
                return null;
            return new StretchTile.FillColor(new Color(argb, true));
        }

        private static void _drawRegion(
            final Graphics2D g2, final BufferedImage source,
            final int dx1, final int dy1, final int dx2, final int dy2,
            final int sx1, final int sy1, final int sx2, final int sy2
        ) {
            if ( dx2 <= dx1 || dy2 <= dy1 )
                return; // Degenerate tile, nothing to draw (a negative span would mirror the image!).
            g2.drawImage(source, dx1, dy1, dx2, dy2, sx1, sy1, sx2, sy2, null);
        }

        private static void _drawStretched(
            final Graphics2D g2, final StretchTile tile,
            final int dx1, final int dy1, final int dx2, final int dy2
        ) {
            if ( tile instanceof StretchTile.Nothing )
                return;
            if ( dx2 <= dx1 || dy2 <= dy1 )
                return; // Degenerate tile, nothing to draw.
            if ( tile instanceof StretchTile.FillColor ) {
                g2.setColor(((StretchTile.FillColor) tile)._color);
                g2.fillRect(dx1, dy1, dx2 - dx1, dy2 - dy1);
                return;
            }
            final BufferedImage image = ((StretchTile.Image) tile)._image;
            g2.drawImage(image, dx1, dy1, dx2, dy2, 0, 0, image.getWidth(), image.getHeight(), null);
        }
    }

}