ScrollableComponentDelegate.java

package swingtree;

import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import swingtree.api.Configurator;
import swingtree.api.ScrollIncrementSupplier;
import swingtree.layout.Bounds;
import swingtree.layout.Size;

import javax.swing.*;
import java.awt.Component;
import java.awt.Dimension;
import java.util.Objects;
import java.util.function.Supplier;

/**
 * This class is an immutable builder which defines the {@link javax.swing.Scrollable} behavior of a component
 * within a {@link JScrollPane}.
 * So it is in essence a config object that provides information to a scrolling container
 * like {@link JScrollPane}.
 * <p>
 * Instances of this class are exposed to the {@link swingtree.api.Configurator} lambda
 * of the {@link UI#scrollPane(Configurator)} factory method, where you can configure the scrollable behavior
 * according to your needs. <br>
 * This includes setting the preferred size, unit increment, block increment, and whether the component should
 * fit the width or height of the viewport. <br>
 * Here an example demonstrating how the API of this class
 * is typically used:
 * <pre>{@code
 * UI.panel()
 * .withBorderTitled("Scrollable Panel")
 * .add(
 *     UI.scrollPane(conf -> conf
 *         .prefSize(400, 300)
 *         .unitIncrement(20)
 *         .blockIncrement(50)
 *         .fitWidth(true)
 *         .fitHeight(false)
 *     )
 * )
 * }</pre>
 * <p>
 * In most cases, the supplied {@link Configurator} will be called
 * for every call to a method of the underlying {@link javax.swing.Scrollable}
 * component implementation, so the settings you provide can
 * also change dynamically based on the context captured by the lambda.<br>
 * <b>
 *     This however is NOT true in case of the scroll pane wrapping
 *     a {@link JTable} for which the configurator will be called once initially.
 * </b><br>
 * <i>(This is because a table expects to be tightly wrapped by the scroll pane in order to
 * function properly, and so we cannot install a delegation mechanism for you...)</i><br>
 * <p>
 * Also note that this configuration object exposes some additional context
 * information you may find useful when defining its properties like {@link #fitWidth(boolean)},
 * {@link #fitHeight(boolean)}, {@link #unitIncrement(int)}, and so on...<br>
 * Like for example the current {@link #view()}, which implements the {@link javax.swing.Scrollable}
 * and wraps your {@link #content} component. You can also access the {@link #viewport()} as
 * well as the overarching {@link #scrollPane()} overall!<br>
 * Again, the configurator you pass to {@link UI#scrollPane(Configurator)} will be
 * called eagerly (except for tables), so everything you define in there will be completely dynamic,
 * which means your scroll behaviour can dynamically react to the involved components.
 */
public final class ScrollableComponentDelegate
{
    private static final Logger log = LoggerFactory.getLogger(ScrollableComponentDelegate.class);

    /**
     *  Builds a delegate whose defaults are all deferred until they are actually read.
     *  <p>
     *  This matters because a {@link Scrollable} implementation backed by a
     *  {@link Configurator} has to build a fresh delegate for every single question the
     *  scroll pane asks it, and Swing asks several of those per layout pass. Almost every
     *  one of those questions concerns exactly one of the values below, and two of them
     *  (the preferred size and the two fitting flags) can only be answered by measuring a
     *  whole component tree. Computing all of them up front therefore meant measuring
     *  repeatedly to then throw the result away -- once because the caller only wanted a
     *  scroll increment, and once more for every default the configurator overrides
     *  anyway. Deferring them means each one is computed only if it survives configuration
     *  <i>and</i> is asked for, and then only once.
     *
     * @param scrollPane    The {@link JScrollPane} in which the content component is placed.
     * @param content       The user provided content component placed inside the scroll pane.
     * @param preferredSize Supplies the default preferred viewport size when it is first read.
     * @return A new {@link ScrollableComponentDelegate} with lazily computed defaults.
     */
    static ScrollableComponentDelegate of(
        JScrollPane scrollPane,
        JComponent content,
        Supplier<Size> preferredSize
    ) {
        Component view     = scrollPane.getViewport().getView();
        JViewport viewport = scrollPane.getViewport();

        Lazy<Boolean> fitWidth ;
        Lazy<Boolean> fitHeight;
        ScrollIncrementSupplier unitIncrementSupplier;
        ScrollIncrementSupplier blockIncrementSupplier;
        if ( content instanceof Scrollable) {
            Scrollable scrollable = (Scrollable)content;
            fitWidth  = Lazy.from(scrollable::getScrollableTracksViewportWidth);
            fitHeight = Lazy.from(scrollable::getScrollableTracksViewportHeight);
            unitIncrementSupplier = (a,b,c) -> {
                int orientation = b == UI.Align.HORIZONTAL ? SwingConstants.HORIZONTAL : SwingConstants.VERTICAL;
                return scrollable.getScrollableUnitIncrement(a.toRectangle(),orientation,c);
            };
            blockIncrementSupplier = (a,b,c) -> {
                int orientation = b == UI.Align.HORIZONTAL ? SwingConstants.HORIZONTAL : SwingConstants.VERTICAL;
                return scrollable.getScrollableBlockIncrement(a.toRectangle(),orientation,c);
            };
        } else {
            int averageBlockIncrement  = 10;
            int averageUnitIncrement   = 10;
            try {
                int verticalBlockIncrement   = scrollPane.getVerticalScrollBar().getBlockIncrement();
                int horizontalBlockIncrement = scrollPane.getHorizontalScrollBar().getBlockIncrement();
                averageBlockIncrement = (verticalBlockIncrement + horizontalBlockIncrement) / 2;
            } catch ( Exception e ) {
                log.error(SwingTree.get().logMarker(), "Error while calculating average block increment for scrollable component.", e);
            }
            try {
                int verticalUnitIncrement   = scrollPane.getVerticalScrollBar().getUnitIncrement();
                int horizontalUnitIncrement = scrollPane.getHorizontalScrollBar().getUnitIncrement();
                averageUnitIncrement = (verticalUnitIncrement + horizontalUnitIncrement) / 2;
            } catch ( Exception e ) {
                log.error(SwingTree.get().logMarker(), "Error while calculating average unit increment for scrollable component.", e);
            }
            // Note: a view's preferred size is not necessarily cached (a JComponent recomputes it on every call),
            // and depending on what sits behind it this single preferred size query can be expensive!
            Lazy<Dimension> viewPreferredSize = Lazy.from(view::getPreferredSize);
            fitWidth  = Lazy.from(() -> viewport.getWidth()  > viewPreferredSize.get().width );
            fitHeight = Lazy.from(() -> viewport.getHeight() > viewPreferredSize.get().height);
            int unitIncrement  = averageUnitIncrement;
            int blockIncrement = averageBlockIncrement;
            unitIncrementSupplier = (a,b,c) -> unitIncrement;
            blockIncrementSupplier = (a,b,c) -> blockIncrement;
        }
        return new ScrollableComponentDelegate(
                    scrollPane, content, view, Lazy.from(preferredSize),
                    unitIncrementSupplier, blockIncrementSupplier,
                    fitWidth, fitHeight
                );
    }

    /**
     *  An internal factory which constructs a {@link ScrollableComponentDelegate}
     *  from a set of explicit default values. This is useful for components
     *  (like the internal panel of {@link swingtree.components.JScrollPanels})
     *  which already implement {@link Scrollable} but want to expose a
     *  {@link swingtree.api.Configurator} based customization mechanism without
     *  re-entering their own {@link Scrollable} methods to compute the defaults.
     *
     * @param scrollPane     The {@link JScrollPane} in which the content component is placed.
     * @param content        The user provided content component placed inside the scroll pane.
     * @param preferredSize  The preferred viewport size to expose as the default.
     * @param unitIncrement  The default {@link ScrollIncrementSupplier} for unit increments.
     * @param blockIncrement The default {@link ScrollIncrementSupplier} for block increments.
     * @param fitWidth       Whether the viewport should force the content's width to match its own.
     * @param fitHeight      Whether the viewport should force the content's height to match its own.
     * @return A new {@link ScrollableComponentDelegate} populated with the supplied defaults.
     */
    public static ScrollableComponentDelegate of(
        JScrollPane             scrollPane,
        JComponent              content,
        Size                    preferredSize,
        ScrollIncrementSupplier unitIncrement,
        ScrollIncrementSupplier blockIncrement,
        boolean                 fitWidth,
        boolean                 fitHeight
    ) {
        Objects.requireNonNull(scrollPane);
        Objects.requireNonNull(content);
        Objects.requireNonNull(preferredSize);
        Objects.requireNonNull(unitIncrement);
        Objects.requireNonNull(blockIncrement);
        Component view = scrollPane.getViewport().getView();
        return new ScrollableComponentDelegate(
                    scrollPane, content, view, Lazy.of(preferredSize),
                    unitIncrement, blockIncrement, Lazy.of(fitWidth), Lazy.of(fitHeight)
                );
    }

    private final JScrollPane             _scrollPane;
    private final JComponent              _content;
    private final Component               _view;
    private final Lazy<Size>              _preferredSize;
    private final ScrollIncrementSupplier _unitIncrement;
    private final ScrollIncrementSupplier _blockIncrement;
    private final Lazy<Boolean>           _fitWidth;
    private final Lazy<Boolean>           _fitHeight;


    private ScrollableComponentDelegate(
        JScrollPane             scrollPane,
        JComponent              content,
        Component               view,
        Lazy<Size>              preferredSize,
        ScrollIncrementSupplier unitIncrement,
        ScrollIncrementSupplier blockIncrement,
        Lazy<Boolean>           fitWidth,
        Lazy<Boolean>           fitHeight
    ) {
        _scrollPane     = scrollPane;
        _content        = content;
        _view           = view;
        _preferredSize  = preferredSize;
        _unitIncrement  = unitIncrement;
        _blockIncrement = blockIncrement;
        _fitWidth       = fitWidth;
        _fitHeight      = fitHeight;
    }

    /**
     * Creates an updated scrollable config with the
     * preferred size of the viewport for a view component.
     * For example, the preferred size of a <code>JList</code> component
     * is the size required to accommodate all the cells in its list.
     * However, the value of <code>preferredScrollableViewportSize</code>
     * is the size required for <code>JList.getVisibleRowCount</code> rows.
     * A component without any properties that would affect the viewport
     * size should just return <code>getPreferredSize</code> here.
     *
     * @param width  The preferred width of a <code>JViewport</code> whose view a
     *               <code>Scrollable</code> configured by this config object.
     * @param height The preferred height of a <code>JViewport</code> whose view a
     *               <code>Scrollable</code> configured by this config object.
     * @return A new instance of {@link ScrollableComponentDelegate} with the updated preferred size.
     * @see JViewport#getPreferredSize
     */
    public ScrollableComponentDelegate prefSize( int width, int height ) {
        return new ScrollableComponentDelegate(
                _scrollPane, _content, _view, Lazy.of(Size.of(width, height)), _unitIncrement, _blockIncrement, _fitWidth, _fitHeight
            );
    }

    /**
     * Configures the preferred <b>viewport size</b> which the view component may or may not
     * fill out depending on other configurations (see {@link #fitWidth(boolean)}, {@link #fitHeight(boolean)}).
     * Internally this translates to {@link Scrollable#getPreferredScrollableViewportSize()}.
     * For example, the preferred size of a <code>JList</code> component
     * is the size required to accommodate all the cells in its list.
     * However, the value of <code>preferredScrollableViewportSize</code>
     * is the size required for <code>JList.getVisibleRowCount</code> rows.
     * A component without any properties that would affect the viewport
     * size should just return <code>getPreferredSize</code> here.
     *
     * @param preferredSize The preferred size of the component.
     * @return A new instance of {@link ScrollableComponentDelegate} with the updated preferred size.
     * @see JViewport#getPreferredSize
     * @throws NullPointerException If the preferred size is null, use {@link Size#unknown()}
     *                              to indicate that the preferred size is unknown.
     */
    public ScrollableComponentDelegate prefSize( Size preferredSize ) {
        Objects.requireNonNull(preferredSize);
        if ( preferredSize.equals(Size.unknown()) )
            return this;
        return new ScrollableComponentDelegate(
                _scrollPane, _content, _view, Lazy.of(preferredSize), _unitIncrement, _blockIncrement, _fitWidth, _fitHeight
            );
    }

    /**
     * Creates an updated scrollable config with the specified unit increment.
     * The unit increment is the amount to scroll when the user requests a unit scroll.
     * For example, this could be the amount to scroll when the user presses the arrow keys.
     * Components that display logical rows or columns should compute
     * the scroll increment that will completely expose one new row
     * or column, depending on the value of orientation.  Ideally,
     * components should handle a partially exposed row or column by
     * returning the distance required to completely expose the item.
     * <p>
     * Scrolling containers, like JScrollPane, will use this increment value
     * each time the user requests a unit scroll.
     *
     * @param unitIncrement The unit increment value.
     * @return A new instance of {@link ScrollableComponentDelegate} with the updated unit increment.
     * @see JScrollBar#setUnitIncrement
     */
    public ScrollableComponentDelegate unitIncrement( int unitIncrement ) {
        return new ScrollableComponentDelegate(
                _scrollPane, _content, _view, _preferredSize, (a, b, c)->unitIncrement, _blockIncrement, _fitWidth, _fitHeight
            );
    }

    /**
     * Creates an updated scrollable config with the specified unit increment supplier,
     * (see {@link ScrollIncrementSupplier}) which takes the visible rectangle,
     * orientation and direction as arguments and returns the unit increment for the given context. <br>
     * The unit increment is the amount to scroll when the user requests a unit scroll.
     * For example, this could be the amount to scroll when the user presses the arrow keys.
     * Components that display logical rows or columns should compute
     * the scroll increment that will completely expose one new row
     * or column, depending on the value of orientation.  Ideally,
     * components should handle a partially exposed row or column by
     * returning the distance required to completely expose the item.
     * <p>
     * Scrolling containers, like JScrollPane, will use this increment value
     * each time the user requests a unit scroll.
     *
     * @param unitIncrement A {@link ScrollIncrementSupplier} that returns the unit increment for the given context.
     * @return A new instance of {@link ScrollableComponentDelegate} with the updated unit increment supplier.
     * @see JScrollBar#setUnitIncrement
     */
    public ScrollableComponentDelegate unitIncrement( ScrollIncrementSupplier unitIncrement ) {
        return new ScrollableComponentDelegate(
                _scrollPane, _content, _view, _preferredSize, unitIncrement, _blockIncrement, _fitWidth, _fitHeight
            );
    }

    /**
     * Creates an updated scrollable config with the specified block increment.
     * The block increment is the amount to scroll when the user requests a block scroll.
     * For example, this could be the amount to scroll when the user presses the page up or page down keys.
     * Components that display logical rows or columns should compute
     * the scroll increment that will completely expose one block
     * of rows or columns, depending on the value of orientation.
     * <p>
     * Scrolling containers, like JScrollPane, will use this increment value
     * each time the user requests a block scroll.
     *
     * @param blockIncrement The block increment value.
     * @return A new instance of {@link ScrollableComponentDelegate} with the updated block increment.
     * @see JScrollBar#setBlockIncrement
     */
    public ScrollableComponentDelegate blockIncrement( int blockIncrement ) {
        return new ScrollableComponentDelegate(
                _scrollPane, _content, _view, _preferredSize, _unitIncrement, (a, b, c)->blockIncrement, _fitWidth, _fitHeight
            );
    }

    /**
     * Creates an updated scrollable config with the specified block increment supplier,
     * (see {@link ScrollIncrementSupplier}) which takes the visible rectangle,
     * orientation and direction as arguments and returns the block increment for the given context. <br>
     * The block increment is the amount to scroll when the user requests a block scroll.
     * For example, this could be the amount to scroll when the user presses the page up or page down keys.
     * Components that display logical rows or columns should compute
     * the scroll increment that will completely expose one block
     * of rows or columns, depending on the value of orientation.
     * <p>
     * Scrolling containers, like JScrollPane, will use this increment value
     * each time the user requests a block scroll.
     *
     * @param blockIncrement A {@link ScrollIncrementSupplier} that returns the block increment for the given context.
     * @return A new instance of {@link ScrollableComponentDelegate} with the updated block increment supplier.
     * @see JScrollBar#setBlockIncrement
     */
    public ScrollableComponentDelegate blockIncrement( ScrollIncrementSupplier blockIncrement ) {
        return new ScrollableComponentDelegate(
                _scrollPane, _content, _view, _preferredSize, _unitIncrement, blockIncrement, _fitWidth, _fitHeight
            );
    }

    /**
     * Set this to true if a viewport should always force the width of this
     * <code>Scrollable</code> to match the width of the viewport.
     * For example a normal
     * text view that supported line wrapping would return true here, since it
     * would be undesirable for wrapped lines to disappear beyond the right
     * edge of the viewport.  Note that returning true for a Scrollable
     * whose ancestor is a JScrollPane effectively disables horizontal
     * scrolling.
     * <p>
     * Scrolling containers, like JViewport, will use this method each
     * time they are validated.
     *
     * @param fitWidth If true, the viewport will force the Scrollables width to match its own.
     * @return A new scroll config with the desired width fitting mode, which,
     *          if true, makes the viewport force the Scrollables width to match its own.
     */
    public ScrollableComponentDelegate fitWidth( boolean fitWidth ) {
        return new ScrollableComponentDelegate(
                _scrollPane, _content, _view, _preferredSize, _unitIncrement, _blockIncrement, Lazy.of(fitWidth), _fitHeight
        );
    }

    /**
     * Set this to true if a viewport should always force the height of this
     * Scrollable to match the height of the viewport.  For example a
     * columnar text view that flowed text in left to right columns
     * could effectively disable vertical scrolling by returning
     * true here.
     * <p>
     * Scrolling containers, like JViewport, will use this method each
     * time they are validated.
     *
     * @param fitHeight If true, the viewport will force the Scrollables height to match its own.
     * @return A new scroll config with the desired width fitting mode, which,
     *          if true, makes a viewport force the Scrollables height to match its own.
     */
    public ScrollableComponentDelegate fitHeight( boolean fitHeight ) {
        return new ScrollableComponentDelegate(
                _scrollPane, _content, _view, _preferredSize, _unitIncrement, _blockIncrement, _fitWidth, Lazy.of(fitHeight)
        );
    }

    /**
     * Returns the scroll pane that contains the scrollable component
     * this configuration is for.
     *
     * @return The scroll pane that contains the scrollable component.
     */
    public JScrollPane scrollPane() {
        return _scrollPane;
    }

    /**
     * Returns the viewport of the scroll pane that contains the {@link javax.swing.Scrollable} component
     * this configuration is for.
     *
     * @return The viewport of the scroll pane that contains the scrollable component.
     */
    public JViewport viewport() {
        return _scrollPane.getViewport();
    }

    /**
     * Returns the user provided content component that is contained in the scroll pane
     * and which is wrapped by a view component implementing the {@link javax.swing.Scrollable}
     * interface configured by this {@link ScrollableComponentDelegate}.
     * <b>
     *     The content component is effectively the component supplied to
     *     the {@link UIForAnyScrollPane#add(Component[])} method.
     *     (Please note that a scroll pane can only ever hold a single content component)
     * </b>
     *
     * @return The content component that is contained within the scroll pane
     *         and wrapped by the {@link #view()} component.
     */
    public JComponent content() {
        return _content;
    }

    /**
     * Returns the view component implementing the {@link javax.swing.Scrollable} interface and which
     * is placed directly in the scroll panes {@link #viewport()} through {@link JViewport#setView(Component)}.<br>
     * This is the main UI component that is configured by this {@link ScrollableComponentDelegate}.
     *
     * @return The view component that is contained within the scroll pane.
     */
    public Component view() {
        return _view;
    }

    /**
     * Returns the preferred viewport size configured on this delegate.
     * This is the value reported to the scroll pane through
     * {@link Scrollable#getPreferredScrollableViewportSize()}.
     *
     * @return The configured preferred viewport size of the scrollable content.
     */
    public Size preferredSize() {
        return _preferredSize.get();
    }

    /**
     * Computes the unit increment value reported to the scroll pane through
     * {@link Scrollable#getScrollableUnitIncrement(java.awt.Rectangle, int, int)}.
     *
     * @param viewRectangle The view area visible within the viewport.
     * @param orientation Either {@link UI.Align#VERTICAL} or {@link UI.Align#HORIZONTAL}.
     * @param direction Less than zero to scroll up/left, greater than zero for down/right.
     * @return The configured unit increment for the given context.
     */
    public int unitIncrement(
        Bounds   viewRectangle,
        UI.Align orientation,
        int      direction
    ) {
        return _unitIncrement.get(viewRectangle, orientation, direction);
    }

    /**
     * Computes the block increment value reported to the scroll pane through
     * {@link Scrollable#getScrollableBlockIncrement(java.awt.Rectangle, int, int)}.
     *
     * @param viewRectangle The view area visible within the viewport.
     * @param orientation Either {@link UI.Align#VERTICAL} or {@link UI.Align#HORIZONTAL}.
     * @param direction Less than zero to scroll up/left, greater than zero for down/right.
     * @return The configured block increment for the given context.
     */
    public int blockIncrement(
        Bounds   viewRectangle,
        UI.Align orientation,
        int      direction
    ) {
        return _blockIncrement.get(viewRectangle, orientation, direction);
    }

    /**
     * Indicates whether the viewport should force the scrollable content's width
     * to match its own. This is the value reported to the scroll pane through
     * {@link Scrollable#getScrollableTracksViewportWidth()}.
     *
     * @return {@code true} if the viewport should force the width to match the viewport,
     *         {@code false} otherwise.
     */
    public boolean fitWidth() {
        return _fitWidth.get();
    }

    /**
     * Indicates whether the viewport should force the scrollable content's height
     * to match its own. This is the value reported to the scroll pane through
     * {@link Scrollable#getScrollableTracksViewportHeight()}.
     *
     * @return {@code true} if the viewport should force the height to match the viewport,
     *         {@code false} otherwise.
     */
    public boolean fitHeight() {
        return _fitHeight.get();
    }

    @Override
    public String toString() {
        return this.getClass().getSimpleName() + "[" +
                "preferredSize="  + this.preferredSize() + ", " +
                "unitIncrement="  + _unitIncrement       + ", " +
                "blockIncrement=" + _blockIncrement      + ", " +
                "fitWidth="       + this.fitWidth()      + ", " +
                "fitHeight="      + this.fitHeight()     +
            "]";
    }

    @Override
    public boolean equals( Object obj ) {
        if ( this == obj )
            return true;
        if ( !(obj instanceof ScrollableComponentDelegate) )
            return false;
        ScrollableComponentDelegate that = (ScrollableComponentDelegate) obj;
        return this.preferredSize().equals(that.preferredSize()) &&
               _unitIncrement.equals(that._unitIncrement) &&
               _blockIncrement.equals(that._blockIncrement) &&
               this.fitWidth()  == that.fitWidth() &&
               this.fitHeight() == that.fitHeight();
    }

    @Override
    public int hashCode() {
        return Objects.hash(this.preferredSize(), _unitIncrement, _blockIncrement, this.fitWidth(), this.fitHeight());
    }

    /**
     *  A value which is either known up front or else computed on demand, at most once.
     *  <p>
     *  The delegate hands the same instance to every copy of itself produced by its wither
     *  methods, which is what keeps a default from being computed twice when a configurator
     *  reads it and the scroll pane then reads it again. Note that the value a {@code Lazy}
     *  reports never changes; only the moment at which it is determined does. Like the
     *  Swing components it measures, it belongs to the event dispatch thread and is not
     *  safe to share across threads.
     *
     * @param <T> The type of the value held by this.
     */
    private static final class Lazy<T>
    {
        static <T> Lazy<T> of( T value ) {
            return new Lazy<>(null, Objects.requireNonNull(value));
        }

        static <T> Lazy<T> from( Supplier<T> supplier ) {
            return new Lazy<>(Objects.requireNonNull(supplier), null);
        }

        private @Nullable Supplier<T> _supplier;
        private @Nullable T           _value;

        private Lazy( @Nullable Supplier<T> supplier, @Nullable T value ) {
            _supplier = supplier;
            _value    = value;
        }

        T get() {
            T value = _value;
            if ( value == null ) {
                Supplier<T> supplier = Objects.requireNonNull(_supplier);
                value     = Objects.requireNonNull(supplier.get());
                _value    = value;
                _supplier = null; // Lets the supplier and everything it captured be collected.
            }
            return value;
        }
    }

}