freecol/src/net/sf/freecol/client/gui/panel/WrapLayout.java

528 lines
19 KiB
Java

/**
* Copyright (C) 2002-2023 The FreeCol Team
*
* This file is part of FreeCol.
*
* FreeCol is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 2 of the License, or
* (at your option) any later version.
*
* FreeCol is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with FreeCol. If not, see <http://www.gnu.org/licenses/>.
*/
package net.sf.freecol.client.gui.panel;
import java.awt.Component;
import java.awt.Container;
import java.awt.Dimension;
import java.awt.Insets;
import java.awt.LayoutManager;
import java.awt.Point;
import java.awt.Rectangle;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import javax.swing.JScrollPane;
import javax.swing.SwingUtilities;
/**
* FlowLayout like layout manager that supports automatic wrapping to a new line when needed.
*/
public class WrapLayout implements LayoutManager {
private static final int MAX_COMPRESS_TRIES = 3;
public enum HorizontalAlignment {
LEFT,
CENTER,
RIGHT;
}
public enum LayoutStyle {
/**
* Layout the components using the narrowest width per row that
* still does not increase the height.
*/
BALANCED,
/**
* Layout the components from top to bottom, only making a new row
* when the width would otherwise increase beyond the bounds.
*/
PREFER_TOP,
/**
* Layout the components from bottom to top, only making a new row
* when the width would otherwise increase beyond the bounds.
*
* Note that the components is in the same order as when using
* PREFER_TOP, but the widest rows will tend to occur more often
* at the bottom rows rather than the top rows.
*/
PREFER_BOTTOM
}
public enum HorizontalGap {
/**
* No extra horizontal gaps will be added between components.
*/
NONE,
/**
* Automatic horizontal gaps for every row.
*/
AUTO,
/**
* Automatic horizontal gaps only for the first row.
*/
AUTO_TOP,
/**
* Automatic horizontal gaps only for the last row.
*/
AUTO_BOTTOM
}
private HorizontalAlignment horizontalAlignment = HorizontalAlignment.CENTER;
private LayoutStyle layoutStyle = LayoutStyle.BALANCED;
private HorizontalGap horizontalGap = HorizontalGap.AUTO;
private Dimension forceComponentSize = null;
private boolean allComponentsWithTheSameSize = false;
private int minHorizontalGap = 0;
private int minVerticalGap = 0;
private int maxHorizontalGap = 0;
/**
* Creates a {@code WrapLayout} with default settings. Specify layout rules using the
* {@code with*} methods.
*
* @see #withHorizontalAlignment(HorizontalAlignment)
* @see #withLayoutStyle(LayoutStyle)
* @see #withHorizontalGap(HorizontalGap, int)
*/
public WrapLayout() {
}
/**
* Sets the horizontal alignment.
* @param horizontalAlignment The alignment to be used.
* @return This object, in order to support method chaining.
*/
public WrapLayout withHorizontalAlignment(HorizontalAlignment horizontalAlignment) {
this.horizontalAlignment = Objects.requireNonNull(horizontalAlignment, "horizontalAlignment");
return this;
}
/**
* Chooses the style when laying out components.
* @param layoutStyle The style to be used.
* @return This object, in order to support method chaining.
*/
public WrapLayout withLayoutStyle(LayoutStyle layoutStyle) {
this.layoutStyle = Objects.requireNonNull(layoutStyle, "layoutStyle");
return this;
}
/**
* Defines how horizontal gaps will be handled.
*
* @param horizontalGap The method for choosing the gap size.
* @return This object, in order to support method chaining.
*/
public WrapLayout withHorizontalGap(HorizontalGap horizontalGap) {
this.horizontalGap = Objects.requireNonNull(horizontalGap, "horizontalGap");
this.minHorizontalGap = 0;
this.maxHorizontalGap = Integer.MAX_VALUE;
return this;
}
/**
* Defines how horizontal gaps will be handled.
*
* @param horizontalGap The method for choosing the gap size.
* @param minHorizontalGap The minimum horizontal gap to always be placed between
* components (even if there is no room for it).
* @param maxHorizontalGap The maximum allowed horizontal gap between components.
* @return This object, in order to support method chaining.
*/
public WrapLayout withHorizontalGap(HorizontalGap horizontalGap, int minHorizontalGap, int maxHorizontalGap) {
if (maxHorizontalGap < 0) {
throw new IllegalArgumentException("maxHorizontalGap must be >= 0. Argument: " + maxHorizontalGap);
}
this.minHorizontalGap = minHorizontalGap;
this.horizontalGap = Objects.requireNonNull(horizontalGap, "horizontalGap");
this.maxHorizontalGap = maxHorizontalGap;
return this;
}
/**
* Forces all child components to have the given size.
*
* @param forceComponentSize The component size.
* @return This object, in order to support method chaining.
*/
public WrapLayout withForceComponentSize(Dimension forceComponentSize) {
this.forceComponentSize = forceComponentSize;
this.allComponentsWithTheSameSize = false;
return this;
}
/**
* Uses the biggest preferred/minimum size for all child components.
*
* @param allComponentsWithTheSameSize {@code true} if all components should
* get the same same.
* @return This object, in order to support method chaining.
*/
public WrapLayout withAllComponentsWithTheSameSize(boolean allComponentsWithTheSameSize) {
this.allComponentsWithTheSameSize = allComponentsWithTheSameSize;
this.forceComponentSize = null;
return this;
}
/**
* Returns the preferred dimensions for this layout given the <i>visible</i>
* components in the specified target container.
*
* @param target the component which needs to be laid out
* @return the preferred dimensions to lay out the subcomponents of the
* specified container
*/
@Override
public Dimension preferredLayoutSize(Container target) {
return layoutSize(target, true);
}
/**
* Returns the minimum dimensions needed to layout the <i>visible</i> components
* contained in the specified target container.
*
* @param target the component which needs to be laid out
* @return the minimum dimensions to lay out the subcomponents of the specified
* container
*/
@Override
public Dimension minimumLayoutSize(Container target) {
Dimension minimum = layoutSize(target, false);
return minimum;
}
/**
* Determines the layout size. To be used by other layout managers using
* {@code WrapLayout} as a fallback.
*/
public Dimension layoutSize(Container target, int targetWidth, boolean preferred) {
synchronized (target.getTreeLock()) {
final Layout mostCompactLayout = determineLayout(target, targetWidth, preferred);
return mostCompactLayout.dimension;
}
}
private Dimension layoutSize(Container target, boolean preferred) {
synchronized (target.getTreeLock()) {
final Layout mostCompactLayout = determineLayout(target, preferred);
return mostCompactLayout.dimension;
}
}
private Layout determineLayout(Container target, boolean preferred) {
int targetWidth = target.getSize().width;
Container container = target;
while (container.getSize().width == 0 && container.getParent() != null) {
container = container.getParent();
}
targetWidth = container.getSize().width;
if (targetWidth == 0) {
targetWidth = Integer.MAX_VALUE;
}
return determineLayout(target, targetWidth, preferred);
}
private Layout determineLayout(Container target, int targetWidth, boolean preferred) {
final boolean reverseComponentOrder = (layoutStyle == LayoutStyle.PREFER_BOTTOM);
Layout mostCompactLayout = internalLayoutSize(target, targetWidth, preferred, reverseComponentOrder);
if (targetWidth <= 0) {
return mostCompactLayout;
}
if (layoutStyle != LayoutStyle.BALANCED) {
return mostCompactLayout;
}
for (int i=0; i<MAX_COMPRESS_TRIES; i++) {
final Layout candidateLayout = internalLayoutSize(target, mostCompactLayout.getWidestColumnWidth() - 1, preferred, reverseComponentOrder);
if (candidateLayout.dimension.height <= mostCompactLayout.dimension.height) {
mostCompactLayout = candidateLayout;
} else {
break;
}
}
return mostCompactLayout;
}
private Layout internalLayoutSize(Container target, int targetWidth, boolean preferred, boolean layoutBottomToTop) {
final Insets insets = target.getInsets();
final int horizontalInsetsAndGap = insets.left + insets.right + (minHorizontalGap * 2);
final int maxWidth = targetWidth - horizontalInsetsAndGap;
final List<Row> rows = new ArrayList<>();
final Dimension currentLayoutSize = new Dimension(0, 0);
Dimension sharedComponentSize = forceComponentSize;
if (allComponentsWithTheSameSize) {
sharedComponentSize = new Dimension(0, 0);
for (Component component : getVisibleComponents(target, layoutBottomToTop)) {
final Dimension d = preferred ? component.getPreferredSize() : component.getMinimumSize();
if (d.width > sharedComponentSize.width) {
sharedComponentSize.width = d.width;
}
if (d.height > sharedComponentSize.height) {
sharedComponentSize.height = d.height;
}
}
}
List<Child> currentChildren = new ArrayList<>();
Dimension currentRowSize = new Dimension(0, 0);
for (Component component : getVisibleComponents(target, layoutBottomToTop)) {
final Dimension d;
if (sharedComponentSize != null) {
d = sharedComponentSize;
} else {
d = preferred ? component.getPreferredSize() : component.getMinimumSize();
}
// Can't add the component to current row. Start a new row.
if (currentRowSize.width + d.width > maxWidth) {
updateCurrentLayoutSize(currentLayoutSize, currentRowSize);
rows.add(new Row(currentRowSize, currentChildren));
currentChildren = new ArrayList<>();
currentRowSize = new Dimension(0, 0);
}
// Add a horizontal gap for all components after the first
if (currentRowSize.width != 0) {
currentRowSize.width += minHorizontalGap;
}
currentChildren.add(new Child(component, new Rectangle(currentRowSize.width, currentLayoutSize.height, d.width, d.height)));
currentRowSize.width += d.width;
currentRowSize.height = Math.max(currentRowSize.height, d.height);
}
currentLayoutSize.width = Math.max(currentLayoutSize.width, currentRowSize.width);
currentLayoutSize.height += currentRowSize.height;
rows.add(new Row(currentRowSize, currentChildren));
currentLayoutSize.width += horizontalInsetsAndGap;
currentLayoutSize.height += insets.top + insets.bottom;
// When using a scroll pane or the DecoratedLookAndFeel we need to
// make sure the preferred size is less than the size of the
// target containter so shrinking the container size works
// correctly. Removing the horizontal gap is an easy way to do this.
Container scrollPane = SwingUtilities.getAncestorOfClass(JScrollPane.class, target);
if (scrollPane != null && target.isValid()) {
currentLayoutSize.width -= (minHorizontalGap + 1);
}
if (layoutBottomToTop) {
reverseLayout(rows);
}
return new Layout(currentLayoutSize, rows);
}
private void reverseLayout(final List<Row> rows) {
if (rows.isEmpty()) {
return;
}
Collections.reverse(rows);
int y = 0;
int rowNum = 0;
final int lastRowIndex = rows.size() - 1;
for (Row row : rows) {
Collections.reverse(row.children);
int x = 0;
int childNum = 0;
final int lastChildIndex = row.children.size() - 1;
for (Child child : row.children) {
child.bounds.x = x;
child.bounds.y = y;
x += child.bounds.width;
if (childNum != lastChildIndex) {
x += minHorizontalGap;
}
childNum++;
}
y += row.size.height;
if (rowNum != lastRowIndex) {
y += minVerticalGap;
}
rowNum++;
}
}
private void updateCurrentLayoutSize(Dimension currentLayoutSize, Dimension newRowSize) {
currentLayoutSize.width = Math.max(currentLayoutSize.width, newRowSize.width);
currentLayoutSize.height += minVerticalGap;
currentLayoutSize.height += newRowSize.height;
}
/**
* {@inheritDoc}
*/
@Override
public void layoutContainer(Container parent) {
synchronized (parent.getTreeLock()) {
final Dimension size = parent.getSize();
Layout layout = determineLayout(parent, size.width, true);
if (layout.dimension.width > size.width || layout.dimension.height > size.height) {
layout = determineLayout(parent, size.width, false);
}
applyLayout(parent, size, layout);
}
}
/**
* Layout of the container. To be used by other layout managers using
* {@code WrapLayout} as a fallback.
*/
public void layoutContainer(Container parent, boolean preferred) {
synchronized (parent.getTreeLock()) {
final Dimension size = parent.getSize();
final Layout layout = determineLayout(parent, size.width, preferred);
applyLayout(parent, size, layout);
}
}
private void applyLayout(Container parent, Dimension size, Layout layout) {
final Point offsetAll = determineOffsetForCentering(size, layout.dimension);
final int rowsSize = layout.rows.size();
int rowNum = 0;
for (Row row : layout.rows) {
final int additionalGapWidthPerChild = determineAdditionalGapWidthPerChild(size, rowsSize, rowNum, row);
final int totalAdditionalWidthFromAllChildren = (row.children.size() > 1) ? additionalGapWidthPerChild * (row.children.size() - 1) : 0;
final int additionalWidth = size.width - row.size.width;
int childNum = 0;
final int offsetRowX = (size.width - row.size.width - totalAdditionalWidthFromAllChildren) / 2;
for (Child child : row.children) {
final Rectangle bounds = child.bounds;
if (additionalGapWidthPerChild > 0) {
if (childNum != 0) {
bounds.x = bounds.x + additionalGapWidthPerChild * childNum;
}
}
if (horizontalAlignment == HorizontalAlignment.CENTER) {
bounds.x = bounds.x + offsetRowX;
} else if (horizontalAlignment == HorizontalAlignment.LEFT) {
// Ignore.
} else if (horizontalAlignment == HorizontalAlignment.RIGHT) {
bounds.x = bounds.x + additionalWidth;
} else {
throw new IllegalStateException("Unknown horizontalAlignment: " + horizontalAlignment);
}
bounds.y = bounds.y + offsetAll.y;
child.component.setBounds(bounds);
childNum++;
}
rowNum++;
}
}
private int determineAdditionalGapWidthPerChild(Dimension size, final int rowsSize, int rowNum, Row row) {
if (row.children.size() <= 1) {
return 0;
}
if (!hasAutoHorizontalGap(rowsSize, rowNum)) {
return 0;
}
return Math.min(maxHorizontalGap, (size.width - row.size.width) / (row.children.size() - 1));
}
private boolean hasAutoHorizontalGap(final int rowsSize, int rowNum) {
return horizontalGap == HorizontalGap.AUTO
|| horizontalGap == HorizontalGap.AUTO_TOP && rowNum == 0
|| horizontalGap == HorizontalGap.AUTO_BOTTOM && rowNum == rowsSize - 1;
}
private List<Component> getVisibleComponents(Container parent, boolean reverseComponentOrder) {
final List<Component> components = new ArrayList<>(parent.getComponentCount());
for (Component c : parent.getComponents()) {
if (!c.isVisible()) {
continue;
}
components.add(c);
}
if (reverseComponentOrder) {
Collections.reverse(components);
}
return components;
}
private final Point determineOffsetForCentering(Dimension displaySize, Dimension contentSize) {
return new Point((displaySize.width - contentSize.width) / 2,
(displaySize.height - contentSize.height) / 2);
}
@Override
public void addLayoutComponent(String name, Component comp) {}
@Override
public void removeLayoutComponent(Component comp) {}
private static class Layout {
private Dimension dimension;
private List<Row> rows;
private Layout(Dimension dimension, List<Row> rows) {
this.dimension = dimension;
this.rows = rows;
}
int getWidestColumnWidth() {
return rows.stream().mapToInt(r -> r.size.width).max().orElse(0);
}
}
private static class Row {
private Dimension size;
private List<Child> children;
public Row(Dimension size, List<Child> children) {
this.size = size;
this.children = children;
}
}
private static class Child {
private Component component;
private Rectangle bounds;
public Child(Component component, Rectangle bounds) {
this.component = component;
this.bounds = bounds;
}
}
}