ResponsiveGridFlowLayout.java

package swingtree.layout;

import net.miginfocom.layout.LC;
import net.miginfocom.swing.MigLayout;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import swingtree.UI;

import javax.swing.JComponent;
import java.awt.*;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * A flow layout arranges components in a directional flow, much
 * like lines of text in a paragraph.
 */
public final class ResponsiveGridFlowLayout implements LayoutManager2 {

    private static final int NUMBER_OF_COLUMNS = 12;
    private static final Logger log = LoggerFactory.getLogger(ResponsiveGridFlowLayout.class);

    private UI.HorizontalAlignment _alignmentCode;
    private int                    _horizontalGapSize;
    private int                    _verticalGapSize;
    private boolean                _alignOnBaseline;

    /**
     * Constructs a new {@code FlowLayout} with a centered alignment and a
     * default 5-unit horizontal and vertical gap.
     */
    public ResponsiveGridFlowLayout() {
        this(UI.HorizontalAlignment.CENTER, 5, 5);
    }

    /**
     * Constructs a new {@code FlowLayout} with the specified
     * alignment and a default 5-unit horizontal and vertical gap.
     * The value of the alignment argument must be one of
     * {@code FlowLayout.LEFT}, {@code FlowLayout.RIGHT},
     * {@code FlowLayout.CENTER}, {@code FlowLayout.LEADING},
     * or {@code FlowLayout.TRAILING}.
     *
     * @param align the alignment value
     */
    public ResponsiveGridFlowLayout(UI.HorizontalAlignment align) {
        this(align, 5, 5);
    }

    /**
     * Creates a new flow layout manager with the indicated alignment
     * and the indicated horizontal and vertical gaps.
     * <p>
     * The value of the alignment argument must be one of
     * {@code FlowLayout.LEFT}, {@code FlowLayout.RIGHT},
     * {@code FlowLayout.CENTER}, {@code FlowLayout.LEADING},
     * or {@code FlowLayout.TRAILING}.
     *
     * @param align the alignment value
     * @param horizontalGapSize  the horizontal gap between components
     *              and between the components and the
     *              borders of the {@code Container}
     * @param verticalGapSize  the vertical gap between components
     *              and between the components and the
     *              borders of the {@code Container}
     */
    public ResponsiveGridFlowLayout(
        UI.HorizontalAlignment align,
        int horizontalGapSize,
        int verticalGapSize
    ) {
        _alignmentCode     = align;
        _horizontalGapSize = horizontalGapSize;
        _verticalGapSize   = verticalGapSize;
    }

    /**
     * Gets the alignment for this layout.
     * Possible values are {@code UI.HorizontalAlignment.LEFT},
     * {@code UI.HorizontalAlignment.RIGHT}, {@code UI.HorizontalAlignment.CENTER},
     * {@code UI.HorizontalAlignment.LEADING},
     * or {@code UI.HorizontalAlignment.TRAILING}.
     *
     * @return the alignment value for this layout
     * @see #setAlignment
     */
    public UI.HorizontalAlignment getAlignment() {
        return _alignmentCode;
    }

    /**
     * Sets the alignment for this layout.
     * Possible values are
     * <ul>
     * <li>{@code UI.HorizontalAlignment.LEFT}
     * <li>{@code UI.HorizontalAlignment.RIGHT}
     * <li>{@code UI.HorizontalAlignment.CENTER}
     * <li>{@code UI.HorizontalAlignment.LEADING}
     * <li>{@code UI.HorizontalAlignment.TRAILING}
     * </ul>
     *
     * @param align one of the alignment values shown above
     * @see #getAlignment()
     */
    public void setAlignment(UI.HorizontalAlignment align) {
        _alignmentCode = align;
    }

    /**
     * Gets the horizontal gap between components
     * and between the components and the borders
     * of the {@code Container}
     *
     * @return the horizontal gap between components
     * and between the components and the borders
     * of the {@code Container}
     * @see ResponsiveGridFlowLayout#setHorizontalGapSize(int)
     */
    public int horizontalGapSize() {
        return UI.scale(_horizontalGapSize);
    }

    /**
     * Sets the horizontal gap between components and
     * between the components and the borders of the
     * {@code Container}.
     *
     * @param size the horizontal gap between components
     *             and between the components and the borders
     *             of the {@code Container}
     * @see ResponsiveGridFlowLayout#horizontalGapSize()
     */
    public void setHorizontalGapSize(int size) {
        _horizontalGapSize = size;
    }

    /**
     * Gets the vertical gap between components and
     * between the components and the borders of the
     * {@code Container}.
     *
     * @return the vertical gap between components
     * and between the components and the borders
     * of the {@code Container}
     * @see ResponsiveGridFlowLayout#setVerticalGapSize(int)
     */
    public int verticalGapSize() {
        return UI.scale(_verticalGapSize);
    }

    /**
     * Sets the vertical gap between components and between
     * the components and the borders of the {@code Container}.
     *
     * @param size the vertical gap between components
     *             and between the components and the borders
     *             of the {@code Container}
     * @see ResponsiveGridFlowLayout#verticalGapSize()
     */
    public void setVerticalGapSize(int size) {
        _verticalGapSize = size;
    }

    /**
     * Sets whether or not components should be vertically aligned along their
     * baseline.  Components that do not have a baseline will be centered.
     * The default is false.
     *
     * @param alignOnBaseline whether or not components should be
     *                        vertically aligned on their baseline
     */
    public void setAlignOnBaseline(boolean alignOnBaseline) {
        this._alignOnBaseline = alignOnBaseline;
    }

    /**
     * Returns true if components are to be vertically aligned along
     * their baseline.  The default is false.
     *
     * @return true if components are to be vertically aligned along
     * their baseline
     */
    public boolean getAlignOnBaseline() {
        return _alignOnBaseline;
    }

    /**
     * Adds the specified component to the layout.
     * Not used by this class.
     *
     * @param name the name of the component
     * @param comp the component to be added
     */
    @Override
    public void addLayoutComponent( String name, Component comp ) {
    }

    /**
     * Removes the specified component from the layout.
     * Not used by this class.
     *
     * @param comp the component to remove
     * @see java.awt.Container#removeAll
     */
    @Override
    public void removeLayoutComponent( Component comp ) {
    }

    /**
     * Returns the preferred dimensions for this layout given the
     * <i>visible</i> components in the specified target container.
     *
     * @param target the container that needs to be laid out
     * @return the preferred dimensions to lay out the
     * subcomponents of the specified container
     * @see java.awt.Container
     * @see #minimumLayoutSize
     * @see java.awt.Container#getPreferredSize
     */
    @Override
    public Dimension preferredLayoutSize( Container target ) {
        synchronized (target.getTreeLock()) {
            Dimension dim = new Dimension(0, 0);
            int nmembers = target.getComponentCount();
            boolean firstVisibleComponent = true;
            boolean useBaseline = getAlignOnBaseline();
            int maxAscent = 0;
            int maxDescent = 0;
            int hgap = UI.scale(_horizontalGapSize);
            int vgap = UI.scale(_verticalGapSize);

            for (int i = 0; i < nmembers; i++) {
                Component m = target.getComponent(i);
                if (m.isVisible()) {
                    Dimension d = m.getPreferredSize();
                    dim.height = Math.max(dim.height, d.height);
                    if (firstVisibleComponent) {
                        firstVisibleComponent = false;
                    } else {
                        dim.width += hgap;
                    }
                    dim.width += d.width;
                    if (useBaseline) {
                        int baseline = m.getBaseline(d.width, d.height);
                        if (baseline >= 0) {
                            maxAscent = Math.max(maxAscent, baseline);
                            maxDescent = Math.max(maxDescent, d.height - baseline);
                        }
                    }
                }
            }
            if (useBaseline) {
                dim.height = Math.max(maxAscent + maxDescent, dim.height);
            }
            Insets insets = target.getInsets();
            dim.width += insets.left + insets.right + hgap * 2;
            dim.height += insets.top + insets.bottom + vgap * 2;
            return dim;
        }
    }

    /**
     * Returns the minimum dimensions needed to layout the <i>visible</i>
     * components contained in the specified target container.
     *
     * @param target the container that needs to be laid out
     * @return the minimum dimensions to lay out the
     * subcomponents of the specified container
     * @see #preferredLayoutSize
     * @see java.awt.Container
     * @see java.awt.Container#doLayout
     */
    @Override
    public Dimension minimumLayoutSize( Container target ) {
        synchronized (target.getTreeLock()) {
            boolean useBaseline = getAlignOnBaseline();
            Dimension dim = new Dimension(0, 0);
            int nmembers = target.getComponentCount();
            int maxAscent = 0;
            int maxDescent = 0;
            boolean firstVisibleComponent = true;
            int hgap = UI.scale(_horizontalGapSize);
            int vgap = UI.scale(_verticalGapSize);

            for (int i = 0; i < nmembers; i++) {
                Component m = target.getComponent(i);
                if (m.isVisible()) {
                    Dimension d = m.getMinimumSize();
                    dim.height = Math.max(dim.height, d.height);
                    if (firstVisibleComponent) {
                        firstVisibleComponent = false;
                    } else {
                        dim.width += hgap;
                    }
                    dim.width += d.width;
                    if (useBaseline) {
                        int baseline = m.getBaseline(d.width, d.height);
                        if (baseline >= 0) {
                            maxAscent = Math.max(maxAscent, baseline);
                            maxDescent = Math.max(maxDescent,
                                    dim.height - baseline);
                        }
                    }
                }
            }

            if (useBaseline) {
                dim.height = Math.max(maxAscent + maxDescent, dim.height);
            }

            Insets insets = target.getInsets();
            dim.width += insets.left + insets.right + hgap * 2;
            dim.height += insets.top + insets.bottom + vgap * 2;
            return dim;
        }
    }

    /**
     * Centers the elements in the specified row, if there is any slack.
     *
     * @param target      the component which needs to be moved
     * @param cells       an array of cells, one for each component of the target
     * @param x           the x coordinate
     * @param y           the y coordinate
     * @param width       the width dimensions
     * @param height      the height dimensions
     * @param rowStart    the beginning of the row
     * @param rowEnd      the ending of the row
     * @param useBaseline Whether or not to align on baseline.
     * @param ascent      Ascent for the components. This is only valid if
     *                    useBaseline is true.
     * @param descent     Ascent for the components. This is only valid if
     *                    useBaseline is true.
     * @return actual row height
     */
    private int moveComponents(
            Container target, Cell[] cells,
            int x, int y, int width, int height,
            int rowStart, int rowEnd, boolean ltr,
            boolean useBaseline, @Nullable int[] ascent,
            @Nullable int[] descent
    ) {
        int hgap = UI.scale(_horizontalGapSize);
        switch (_alignmentCode) {
            case LEFT:
                x += ltr ? 0 : width;
                break;
            case CENTER:
                x += width / 2;
                break;
            case RIGHT:
                x += ltr ? width : 0;
                break;
            case LEADING:
                break;
            case TRAILING:
                x += width;
                break;
        }
        int maxAscent = 0;
        int nonbaselineHeight = 0;
        int baselineOffset = 0;
        if (useBaseline) {
            Objects.requireNonNull(ascent);
            Objects.requireNonNull(descent);
            int maxDescent = 0;
            for (int i = rowStart; i < rowEnd; i++) {
                Component m = target.getComponent(i);
                if (m.isVisible()) {
                    if (ascent[i] >= 0) {
                        maxAscent = Math.max(maxAscent, ascent[i]);
                        maxDescent = Math.max(maxDescent, descent[i]);
                    } else {
                        nonbaselineHeight = Math.max(m.getHeight(),
                                nonbaselineHeight);
                    }
                }
            }
            height = Math.max(maxAscent + maxDescent, nonbaselineHeight);
            baselineOffset = (height - maxAscent - maxDescent) / 2;
        }
        for (int i = rowStart; i < rowEnd; i++) {
            Component m = target.getComponent(i);
            if (m.isVisible()) {
                Optional<FlowCellConf> optionalFlowCellConf = cells[i].flowCell();
                boolean fillHeight = optionalFlowCellConf.map(FlowCellConf::fill).orElse(false);
                UI.VerticalAlignment verticalAlignment = optionalFlowCellConf.map(FlowCellConf::verticalAlignment).orElse(UI.VerticalAlignment.CENTER);
                int cy;
                if (ascent != null && useBaseline && ascent[i] >= 0) {
                    cy = y + baselineOffset + maxAscent - ascent[i];
                } else {
                    if (fillHeight) {
                        cy = y;
                    } else {
                        if ( verticalAlignment == UI.VerticalAlignment.TOP )
                            cy = y;
                        else if ( verticalAlignment == UI.VerticalAlignment.BOTTOM )
                            cy = y + height - m.getHeight();
                        else // centered:
                            cy = y + (height - m.getHeight()) / 2;
                    }
                }
                if (ltr) {
                    m.setLocation(x, cy);
                } else {
                    m.setLocation(target.getWidth() - x - m.getWidth(), cy);
                }
                x += m.getWidth() + hgap;

                if ( fillHeight ) {
                    m.setSize(m.getWidth(), height);
                }
            }
        }
        return height;
    }

    /**
     * Lays out the container. This method lets each
     * <i>visible</i> component take
     * its preferred size by reshaping the components in the
     * target container in order to satisfy the alignment of
     * this layout manager.
     *
     * @param target the specified component being laid out
     * @see Container
     * @see java.awt.Container#doLayout
     */
    @Override
    public void layoutContainer(Container target) {
        synchronized (target.getTreeLock()) {
            final int hgap = UI.scale(_horizontalGapSize);
            final int vgap = UI.scale(_verticalGapSize);
            final Insets insets = target.getInsets();
            final int maxwidth = target.getWidth() - (insets.left + insets.right + hgap * 2);
            final int generalMaxWidth = target.getPreferredSize().width - (insets.left + insets.right + hgap * 2);
            final int nmembers = target.getComponentCount();
            int x = 0, y = insets.top + vgap;
            int rowh = 0, start = 0;

            Cell[] cells = _createCells(target, nmembers, maxwidth, generalMaxWidth);

            boolean ltr = target.getComponentOrientation().isLeftToRight();
            boolean useBaseline = getAlignOnBaseline();
            int[] ascent = null;
            int[] descent = null;

            if (useBaseline) {
                ascent = new int[nmembers];
                descent = new int[nmembers];
            }

            for (int i = 0; i < nmembers; i++) {
                Component m = cells[i].component();
                if (m.isVisible()) {
                    Dimension d = m.getPreferredSize();
                    try {
                        d = _dimensionsFromCellConf(cells[i], maxwidth).orElse(d);
                    } catch (Exception e) {
                        log.error("Error applying cell configuration", e);
                    }
                    m.setSize(d.width, d.height);

                    if (useBaseline ) {
                        Objects.requireNonNull(ascent);
                        Objects.requireNonNull(descent);
                        int baseline = m.getBaseline(d.width, d.height);
                        if (baseline >= 0) {
                            ascent[i] = baseline;
                            descent[i] = d.height - baseline;
                        } else {
                            ascent[i] = -1;
                        }
                    }
                    if ((x == 0) || ((x + d.width) <= maxwidth)) {
                        if (x > 0) {
                            x += hgap;
                        }
                        x += d.width;
                        rowh = Math.max(rowh, d.height);
                    } else {
                        rowh = moveComponents(
                                target, cells,
                                insets.left + hgap, y,
                                maxwidth - x, rowh, start, i, ltr,
                                useBaseline, ascent, descent
                        );
                        x = d.width;
                        y += vgap + rowh;
                        rowh = d.height;
                        start = i;
                    }
                }
            }
            moveComponents(
                    target, cells,
                    insets.left + hgap, y, maxwidth - x, rowh,
                    start, nmembers, ltr, useBaseline, ascent, descent
            );
        }
    }

    private Cell[] _createCells(
            Container target,
            int nmembers,
            int maxwidth,
            int generalMaxWidth
    ) {
        Cell[] cells = new Cell[nmembers];
        AtomicInteger componentsInRow = new AtomicInteger(0);
        double currentRowSize = 0;
        for (int i = 0; i < nmembers; i++) {
            Component m = target.getComponent(i);
            Optional<Cell> optionalCell = Optional.empty();
            double rowSizeIncrease = 0;
            if (m instanceof JComponent) {
                JComponent jc = (JComponent) m;
                AddConstraint addConstraint = (AddConstraint) jc.getClientProperty(AddConstraint.class);
                if (addConstraint instanceof FlowCell) {
                    FlowCell cell = (FlowCell) addConstraint;
                    optionalCell = cellFromCellConf(target, cell, jc, componentsInRow, maxwidth, generalMaxWidth);
                    rowSizeIncrease += optionalCell.flatMap(Cell::autoSpan)
                                                    .map(FlowCellSpanPolicy::cellsToFill)
                                                    .orElse(0);
                }
            }
            if ( !optionalCell.isPresent() ) {
                double prefComponentWidth = m.getPreferredSize().getWidth();
                if ( maxwidth > 0 && prefComponentWidth > 0 ) {
                    rowSizeIncrease += NUMBER_OF_COLUMNS * prefComponentWidth / maxwidth;
                }
            }

            cells[i] = optionalCell.orElse(new Cell(m, componentsInRow, null, null));

            double newRowSize = currentRowSize + rowSizeIncrease;
            if ( newRowSize < NUMBER_OF_COLUMNS ) {
                // Still room in the row for new components...
                componentsInRow.set(componentsInRow.get() + 1);
                currentRowSize += rowSizeIncrease;
            } else if ( Math.round(newRowSize) == NUMBER_OF_COLUMNS ) {
                // We have a new row with no leftovers.
                componentsInRow.set(componentsInRow.get() + 1);
                componentsInRow = new AtomicInteger(0);
                currentRowSize = 0;
            } else if ( newRowSize > NUMBER_OF_COLUMNS ) {
                // The row does not fit the new component. We need to start a new row.
                componentsInRow = new AtomicInteger(1);
                cells[i].setNumberOfComponents(componentsInRow);
                currentRowSize = rowSizeIncrease; // We have a new row with the current component.
            }
        }
        return cells;
    }

    /**
     * Returns a string representation of this {@code FlowLayout}
     * object and its values.
     *
     * @return a string representation of this layout
     */
    public String toString() {
        String str = "";
        int hgap = UI.scale(_horizontalGapSize);
        int vgap = UI.scale(_verticalGapSize);
        switch (_alignmentCode) {
            case LEFT:
                str = ",align=left";
                break;
            case CENTER:
                str = ",align=center";
                break;
            case RIGHT:
                str = ",align=right";
                break;
            case LEADING:
                str = ",align=leading";
                break;
            case TRAILING:
                str = ",align=trailing";
                break;
            case UNDEFINED:
                str = ",align=?";
                break;
        }
        return getClass().getName() + "[horizontalGap=" + hgap + ",verticalGap=" + vgap + str + "]";
    }

    @Override
    public void addLayoutComponent(Component comp, Object constraints) {
        if (constraints instanceof AddConstraint) {
            if (comp instanceof JComponent) {
                JComponent jc = (JComponent) comp;
                jc.putClientProperty(AddConstraint.class, constraints);
            }
        }
    }

    @Override
    public Dimension maximumLayoutSize(Container target) {
        return new Dimension(Integer.MAX_VALUE, Integer.MAX_VALUE);
    }

    @Override
    public float getLayoutAlignmentX(Container target) {
        return 0;
    }

    @Override
    public float getLayoutAlignmentY(Container target) {
        return 0;
    }

    @Override
    public void invalidateLayout(Container target) {

    }

    public Optional<Cell> cellFromCellConf(
            Component parent,
            FlowCell flowCell,
            Component child,
            AtomicInteger componentCounter,
            int maxWidth,
            int generalMaxWidth
    ) {
        if ( maxWidth <= 0 ) {
            return Optional.empty();
        }
        // How much preferred width the parent actually fills:
        ParentSizeClass currentParentSizeCategory = ParentSizeClass.of(maxWidth, generalMaxWidth);

        boolean shouldFillHeight = false;
        LayoutManager childLayout = ( child instanceof JComponent ) ? ((JComponent) child).getLayout() : null;
        if ( childLayout instanceof MigLayout ) {
            Object layoutConstraints = ((MigLayout) childLayout).getLayoutConstraints();
            // If the child has the "fill" or "filly" constraint, we should fill the height.
            if ( layoutConstraints instanceof String ) {
                String constraints = (String) layoutConstraints;
                shouldFillHeight = constraints.contains("fill") || constraints.contains("filly");
            } else if ( layoutConstraints instanceof LC ) {
                LC lc = (LC) layoutConstraints;
                shouldFillHeight = lc.isFillY();
            }
        }
        Size parentSize = Size.of(parent.getWidth(), parent.getHeight());
        FlowCellConf cellConf = flowCell.fetchConfig(NUMBER_OF_COLUMNS, parentSize, currentParentSizeCategory, shouldFillHeight);
        Optional<FlowCellSpanPolicy> autoSpan = _findNextBestAutoSpan(cellConf, currentParentSizeCategory);
        return autoSpan.map(autoCellSpanPolicy -> new Cell(child, componentCounter, autoCellSpanPolicy, cellConf));
    }

    private Optional<Dimension> _dimensionsFromCellConf( Cell cell, int maxWidth ) {

        if ( maxWidth <= 0 ) {
            return Optional.empty();
        }

        Optional<FlowCellSpanPolicy> autoSpan = cell.autoSpan();
        if (!autoSpan.isPresent()) {
            return Optional.empty();
        }

        int cellsToFill = autoSpan.get().cellsToFill();
        int unusableSpace = ((cell.numberOfComponentsInRow()-1) * UI.scale(_horizontalGapSize));
        int width = ((maxWidth - unusableSpace) * cellsToFill) / NUMBER_OF_COLUMNS;
        Dimension newSize = new Dimension(width, cell.component().getPreferredSize().height);
        return Optional.of(newSize);
    }

    private static Optional<FlowCellSpanPolicy> _findNextBestAutoSpan( FlowCellConf cell, ParentSizeClass targetSize ) {
        Optional<FlowCellSpanPolicy> autoSpan = _find(targetSize.ordinal(), cell);
        if ( autoSpan.isPresent() )
            return autoSpan;

        // We did not find the exact match. Let's try to find the closest match.

        int numberOfSizeClasses = ParentSizeClass.values().length;
        int targetOrdinal = targetSize.ordinal();
        /*
            We want to find the enum value which is closed to the target ordinal.
         */
        int sign = ( targetSize.ordinal() > numberOfSizeClasses / 2 ? 1 : -1 );
        for ( int offset = 1; offset < numberOfSizeClasses; offset++ ) {
            sign = -sign;
            autoSpan = _find(targetOrdinal + offset * sign, cell);
            if ( autoSpan.isPresent() )
                return autoSpan;

            sign = -sign;
            autoSpan = _find(targetOrdinal + offset * sign, cell);
            if ( autoSpan.isPresent() )
                return autoSpan;
        }
        return Optional.empty();
    }

    private static Optional<FlowCellSpanPolicy> _find( int ordinal, FlowCellConf cell ) {
        if ( ordinal < 0 || ordinal >= ParentSizeClass.values().length ) {
            return Optional.empty();
        }
        ParentSizeClass targetSize = ParentSizeClass.values()[ordinal];
        for ( FlowCellSpanPolicy autoSpan : cell.autoSpans() ) {
            if ( autoSpan.parentSize() == targetSize ) {
                return Optional.of(autoSpan);
            }
        }
        return Optional.empty();
    }

    private static final class Cell {

        private final Component component;
        private final @Nullable FlowCellSpanPolicy autoSpan;
        private final @Nullable FlowCellConf cellConf;

        private AtomicInteger numberOfComponents;


        Cell(
                Component component,
                AtomicInteger componentCounter,
                @Nullable FlowCellSpanPolicy autoSpan,
                @Nullable FlowCellConf cellConf
        ) {
            this.component          = component;
            this.numberOfComponents = componentCounter;
            this.autoSpan           = autoSpan;
            this.cellConf           = cellConf;
        }

        public Component component() {
            return component;
        }

        public Optional<FlowCellSpanPolicy> autoSpan() {
            return Optional.ofNullable(autoSpan);
        }

        public Optional<FlowCellConf> flowCell() {
            return Optional.ofNullable(cellConf);
        }

        public int numberOfComponentsInRow() {
            return numberOfComponents.get();
        }

        public void setNumberOfComponents(AtomicInteger numberOfComponents) {
            this.numberOfComponents = numberOfComponents;
        }
    }

}