import java.awt.*; import java.awt.font.FontRenderContext; import java.awt.geom.AffineTransform; import java.util.LinkedList; import java.util.List; /** * The text box class is used to represent a text box that can be drawn to an image. * The text is automatically scaled to fit the text box. * * @author Illusion */ public class TextBox implements BaseImageElement { public static FontRenderContext FONT_RENDER_CONTEXT = new FontRenderContext( new AffineTransform(), true, true); private static final float WRAP_AROUND_RATIO = 1f; // The ratio of characters*fontSize / width required to wrap around instead of scale private static final float SPACING_BETWEEN_LINES = 1.15f; // The spacing between lines of text private final Dimensions dimensions; // The dimensions of the text box. private final WrappedText text; // The text to draw. private TextAlignment alignment; // The alignment of the text. public TextBox(Dimensions dimensions, WrappedText text, TextAlignment alignment) { this.dimensions = dimensions; this.text = text; this.alignment = alignment; } public TextBox(Dimensions dimensions, WrappedText text) { this(dimensions, text, TextAlignment.LEFT); } public TextBox(Dimensions dimensions, String text, int fontSize) { this(dimensions, WrappedText.of(text, new Font("Arial", Font.PLAIN, fontSize))); } public TextBox(BaseImageElement element, WrappedText text, TextAlignment alignment) { // This constructor should be used if you're making text inside a button, the text will automatically be scaled to fit the button. this(new Dimensions(0, 0, element.getBounds().getWidth(), element.getBounds().getHeight()), text, alignment); } public TextBox(BaseImageElement element, WrappedText text) { // This constructor should be used if you're making text inside a button, the text will automatically be scaled to fit the button. this(new Dimensions(0, 0, element.getBounds().getWidth(), element.getBounds().getHeight()), text); } @Override public boolean acceptsClicks(int relX, int relY) { return false; // Text boxes don't accept clicks, underlying elements may do so. } @Override public Dimensions getBounds() { return dimensions; } @Override public void draw(Dimensions imageDimensions, Graphics graphics) { float totalWidth = getTextWidth(this.text); // Get the total width of the text. // if the ratio is greater than the wrap around ratio, we need to wrap around the text. if (totalWidth / dimensions.getWidth() > WRAP_AROUND_RATIO) { tryWrapAround(graphics, imageDimensions); } else { drawScaledText(graphics, imageDimensions); } } /** * Attempts to wrap the text around multiple lines. If the text doesn't have any spaces, it will scale the text to fit the text box. * * @param graphics The graphics object to draw to. * @param imageDimensions The dimensions of the image. */ private void tryWrapAround(Graphics graphics, Dimensions imageDimensions) { List wrappedText = wrapAround(this.text); // Wrap the text around. if (wrappedText.size() == 1) { // If the text is still only one line, scale the text to fit the text box. drawScaledText(graphics, imageDimensions); return; } for (WrappedText text : wrappedText) { graphics.setColor(text.getColor()); graphics.setFont(text.getFont()); int x; int y = dimensions.getHeight() / 2; y += (text.getFont().getSize() * SPACING_BETWEEN_LINES) * wrappedText.indexOf(text); y -= (text.getFont().getSize() * SPACING_BETWEEN_LINES) * (wrappedText.size() - 1) / 2; x = switch (alignment) { case LEFT -> 0; case CENTER -> (dimensions.getWidth() / 2) - (getTextWidth(text) / 2); case RIGHT -> dimensions.getWidth() - getTextWidth(text); }; graphics.drawString(text.getText(), dimensions.getMin().getX() + x, dimensions.getMin().getY() + y); } } /** * Draws the text, scaled to fit the text box. * * @param graphics The graphics object to draw to. * @param imageDimensions The dimensions of the image. */ private void drawScaledText(Graphics graphics, Dimensions imageDimensions) { WrappedText text = scaleToFit(this.text, dimensions.getWidth(), dimensions.getHeight()); // Scale the text to fit the text box. Font font = text.getFont(); // Get the font of the text. graphics.setColor(text.getColor()); graphics.setFont(font); int x; int y = dimensions.getHeight() / 2; x = switch (alignment) { case LEFT -> 0; case CENTER -> (dimensions.getWidth() / 2) - (getTextWidth(text) / 2); case RIGHT -> dimensions.getWidth() - getTextWidth(text); }; // --- DRAWING --- graphics.drawString(text.getText(), dimensions.getMin().getX() + x, dimensions.getMin().getY() + y); } /** * Wraps the text around multiple lines. * * @param text The text to wrap around. * @return A list of wrapped text. */ private List wrapAround(WrappedText text) { WrappedText copy = text.copy(); String contents = copy.getText(); if (!contents.contains(" ")) { return List.of(scaleToFit(copy, dimensions.getWidth(), dimensions.getHeight())); } List lines = new LinkedList<>(); int lastFittingSpace = 0; int lastCut = 0; // let's remove double spaces while (contents.contains(" ")) { contents = contents.replace(" ", " "); } int lastSpace = contents.lastIndexOf(" "); /* * If the character is a space, we need to check if the text fits. * If it does, we need to update the last fitting space. * If it doesn't fit, we need to add the text up to the last fitting space to the list of lines. * And go back to the last fitting space. */ for (int index = 0; index < contents.length(); index++) { char character = contents.charAt(index); if (character == ' ') { String substring = contents.substring(lastCut, index); if (getTextWidth(substring, copy.getFont()) > dimensions.getWidth()) { if (lastFittingSpace == lastCut) { lastFittingSpace = index; } WrappedText line = copy.copy(); line.setText(contents.substring(lastCut, lastFittingSpace)); lines.add(line); lastCut = lastFittingSpace; index = lastFittingSpace; lastFittingSpace = 0; } else { lastFittingSpace = index; } } if (index == lastSpace) { WrappedText line = copy.copy(); line.setText(contents.substring(lastCut + 1)); lines.add(line); } } return lines; // } /** * Scales the text to fit the text box. * * @param text The text to scale. * @param width The width of the text box. * @param height The height of the text box. * @return A copy of the text, scaled to fit the text box. */ private WrappedText scaleToFit(WrappedText text, int width, int height) { WrappedText copy = text.copy(); // We need to copy the text, so we don't modify the original text. Font font = copy.getFont(); // Get the font of the text. float fontSize = font.getSize(); // Get the size of the font. float textWidth = getTextWidth(copy); // Get the width of the text. float textHeight = getTextHeight(copy); // Get the height of the text. float widthRatio = width / textWidth; // Get the ratio of the width of the text box to the width of the text. float heightRatio = height / textHeight; // Get the ratio of the height of the text box to the height of the text. float ratio = Math.min(widthRatio, heightRatio); // Get the minimum ratio, so the text fits in the text box. if (ratio > 1) // If the ratio is greater than 1, we don't need to scale the text. return copy; fontSize *= ratio; // Scale the font size by the ratio. copy.setFont(font.deriveFont(fontSize)); // Set the font of the text to the scaled font. return copy; } /** * Gets the width of the text. * * @param text The text to get the width of. * @return The width of the text, in pixels. */ public int getTextWidth(WrappedText text) { return getTextWidth(text.getText(), text.getFont()); } /** * Gets the width of the text. * * @param text The text to get the width of. * @param font The font of the text. * @return The width of the text, in pixels. */ public int getTextWidth(String text, Font font) { return (int) font.getStringBounds(text, FONT_RENDER_CONTEXT).getWidth(); } /** * Gets the height of the text. * * @param text The text to get the height of. * @return The height of the text, in pixels. */ public int getTextHeight(WrappedText text) { return getTextHeight(text.getText(), text.getFont()); } /** * Gets the height of the text. * * @param text The text to get the height of. * @param font The font of the text. * @return The height of the text, in pixels. */ public int getTextHeight(String text, Font font) { return (int) font.getStringBounds(text, FONT_RENDER_CONTEXT).getHeight(); } /** * Updates the internal text to match the Wrapped text. * * @param text The text to set. */ public void setText(WrappedText text) { this.text.setText(text.getText()); this.text.setFont(text.getFont()); this.text.setColor(text.getColor()); } /** * Updates the internal text to match the given text. * * @param text The text to set. */ public void setText(String text) { this.text.setText(text); } /** * Sets the font size of the text. * * @param fontSize The font size to set. */ public void setFontSize(int fontSize) { this.text.setFont(text.getFont().deriveFont(fontSize)); } /** * Sets the color of the text. * * @param color The color to set. */ public void setColor(Color color) { this.text.setColor(color); } /** * Obtains the wrapped text * * @return The wrapped text. */ public WrappedText getText() { return text; } /** * Obtains the font size of the text. * * @return The font size of the text. */ public int getFontSize() { return text.getFont().getSize(); } /** * Obtains the font family of the text. * * @return The font family of the text. */ public String getFontFamily() { return text.getFont().getFamily(); } /** * Obtains the color of the text. * * @return The color of the text. */ public Color getColor() { return text.getColor(); } /** * Obtains the alignment of the text. * * @return The alignment of the text. */ public TextAlignment getAlignment() { return alignment; } /** * Sets the alignment of the text. * * @param alignment The alignment to set. */ public void setAlignment(TextAlignment alignment) { this.alignment = alignment; } }