ScrollPaneLayoutCorrection.java

package swingtree;

import org.jspecify.annotations.Nullable;

import javax.swing.JComponent;
import javax.swing.JScrollPane;
import javax.swing.JViewport;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.Point;
import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent;
import java.awt.event.ContainerAdapter;
import java.awt.event.ContainerEvent;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

/**
 *  This class corrects two gaps in how Swing lays out a {@link JScrollPane} and its surroundings.
 *  <p>
 *  <b>The parent of the scroll pane:</b>
 *  This class makes a {@link JScrollPane} grow and shrink together with its
 *  content, the same way any other container in a layout does.
 *  <p>
 *  A {@link JScrollPane} is a "validate root", which means that a {@link Component#revalidate()}
 *  of anything inside of it lays out the scroll pane, but never the parent of the scroll pane.
 *  So when the content of a scroll pane grows, Swing marks every ancestor of the scroll pane
 *  as invalid, but it only lays out the scroll pane itself. The scroll pane keeps its old size
 *  even if there is room for a bigger one, and then suddenly jumps to its new size much later,
 *  the next time something else in the window is revalidated.
 *  <p>
 *  This class corrects that by listening for new sizes of the view inside the {@link JViewport}
 *  of the scroll pane. Swing delivers a new size as a resize event after the layout which caused
 *  it has finished, which is when this class calls {@link Component#revalidate()} on the parent
 *  of the scroll pane, so that the parent lays out the scroll pane at its new preferred size.
 *  It keeps listening when the view or the viewport is replaced, and it does nothing for a
 *  scroll pane which is not a validate root, because Swing already lays out its parent.
 *  <p>
 *  The view is also resized whenever the parent gives the scroll pane a new size,
 *  for example on every step of resizing the window. Then the parent has just been laid out,
 *  and laying it out again would double the cost of resizing the window. So this class only
 *  revalidates the parent when the preferred size of the scroll pane changed since the last time,
 *  because only a new preferred size can give the scroll pane a different place in its parent.
 *  <p>
 *  <b>The content of the scroll pane:</b>
 *  A component styled with {@code TextConf.autoPreferredHeight(true)} prefers the height of its
 *  wrapped text at its width, so its preferred height is known only after a layout gave it its width.
 *  SwingTree's components therefore measure their text in their {@code doLayout()}, while their
 *  parent is still being laid out. A {@link Component#revalidate()} called at that moment has no effect,
 *  because Swing marks the parent as laid out when its layout finishes, and Swing paints right after
 *  the layout. Without a correction, the component would be painted once at the height which belongs
 *  to its old width, and laid out at its new height only in a later task on Swing's event thread.
 *  <p>
 *  This class corrects that in {@link #validateTree(Runnable)}, which {@link UI.ScrollPane} calls from
 *  its {@link java.awt.Container#validateTree()}. Swing calls that method both when it lays out the
 *  scroll pane itself and when it lays out a container around it, and in both cases before it paints.
 *  While the scroll pane is being laid out, this class keeps a list in the client property
 *  {@link #COMPONENTS_WITH_NEW_TEXT_HEIGHT} of the scroll pane, to which a component inside it adds itself
 *  when its preferred height changed (see {@code ComponentBackend.measureTextHeightAfterLayoutOf}).
 *  After the layout, this class invalidates the listed components and lays out the scroll pane again,
 *  at most 3 times, so that a height which keeps changing the width (through a scroll bar)
 *  cannot repeat the layout forever.
 *  <p>
 *  The SwingTree native {@link UI.ScrollPane} installs this class on itself.
 */
final class ScrollPaneLayoutCorrection
{
    /** The key of the client property; {@code ComponentBackend} uses the same string to find the list. */
    static final String COMPONENTS_WITH_NEW_TEXT_HEIGHT = "swingtree.componentsWithNewTextHeight";

    private final JScrollPane _ownerScrollPane;
    private @Nullable Dimension _preferredSizeAtLastRevalidation;
    private final ComponentAdapter _revalidateParentWhenTheViewIsResized = new ComponentAdapter() {
        @Override public void componentResized( ComponentEvent e ) {
            if ( !_ownerScrollPane.isValidateRoot() )
                return;
            Dimension preferredSize = _ownerScrollPane.getPreferredSize();
            if ( preferredSize.equals(_preferredSizeAtLastRevalidation) )
                return;
            _preferredSizeAtLastRevalidation = preferredSize;
            Optional.ofNullable(_ownerScrollPane.getParent()).ifPresent(Component::revalidate);
        }
    };
    private final ContainerAdapter _followTheViewWhenItIsReplaced = new ContainerAdapter() {
        @Override public void componentAdded( ContainerEvent e ) { _followTheSizeOf(e.getChild()); }
        @Override public void componentRemoved( ContainerEvent e ) { e.getChild().removeComponentListener(_revalidateParentWhenTheViewIsResized); }
    };

    static ScrollPaneLayoutCorrection installOn( JScrollPane scrollPane ) {
        return new ScrollPaneLayoutCorrection(scrollPane);
    }

    private ScrollPaneLayoutCorrection( JScrollPane scrollPane ) {
        _ownerScrollPane = scrollPane;
        scrollPane.addPropertyChangeListener("viewport", e -> {
            if ( e.getOldValue() instanceof JViewport )
                _stopFollowingTheViewOf((JViewport) e.getOldValue());
            if ( e.getNewValue() instanceof JViewport )
                _followTheViewOf((JViewport) e.getNewValue());
        });
        if ( scrollPane.getViewport() != null )
            _followTheViewOf(scrollPane.getViewport());
    }

    private void _followTheViewOf( JViewport viewport ) {
        viewport.addContainerListener(_followTheViewWhenItIsReplaced);
        if ( viewport.getView() != null )
            _followTheSizeOf(viewport.getView());
    }

    private void _stopFollowingTheViewOf( JViewport viewport ) {
        viewport.removeContainerListener(_followTheViewWhenItIsReplaced);
        if ( viewport.getView() != null )
            viewport.getView().removeComponentListener(_revalidateParentWhenTheViewIsResized);
    }

    private void _followTheSizeOf( Component view ) {
        view.removeComponentListener(_revalidateParentWhenTheViewIsResized);
        view.addComponentListener(_revalidateParentWhenTheViewIsResized);
    }

    void validateTree( Runnable validateTree ) {
        List<JComponent> changed = new ArrayList<>();
        _ownerScrollPane.putClientProperty(COMPONENTS_WITH_NEW_TEXT_HEIGHT, changed);
        JViewport viewport = _ownerScrollPane.getViewport();
        @Nullable Point viewPosition = viewport != null ? viewport.getViewPosition() : null;
        try {
            validateTree.run();
            for ( int pass = 0; pass < 3 && !changed.isEmpty(); pass++ ) {
                List<JComponent> again = new ArrayList<>(changed);
                changed.clear();
                again.forEach(Component::invalidate);
                // The viewport layout may have moved the view position to fit the sizes which the
                // components had before they measured their text, so the next pass starts again
                // from the view position before the first pass.
                if ( viewport != null && viewPosition != null && !viewport.isValid() )
                    viewport.setViewPosition(viewPosition);
                validateTree.run();
            }
        } finally {
            _ownerScrollPane.putClientProperty(COMPONENTS_WITH_NEW_TEXT_HEIGHT, null);
        }
    }
}