/* * Copyright (c) 2003-onwards Shaven Puppy Ltd * All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are * met: * * * Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * * * Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. * * * Neither the name of 'Shaven Puppy' nor the names of its contributors * may be used to endorse or promote products derived from this software * without specific prior written permission. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ package com.shavenpuppy.jglib.opengl; import java.io.ObjectStreamException; import java.io.Serializable; import java.util.ArrayList; import java.util.List; import java.util.StringTokenizer; import org.lwjgl.util.Color; import org.lwjgl.util.ReadableColor; import org.lwjgl.util.ReadableDimension; import org.lwjgl.util.ReadablePoint; import org.lwjgl.util.ReadableRectangle; import org.lwjgl.util.Rectangle; import com.shavenpuppy.jglib.Resources; import com.shavenpuppy.jglib.resources.MappedColor; import com.shavenpuppy.jglib.sprites.SimpleRenderable; import com.shavenpuppy.jglib.sprites.SimpleRenderer; import com.shavenpuppy.jglib.util.Decodeable; import static org.lwjgl.opengl.GL11.*; /** * Displays styled text in a box, automatically wrapping words. */ public class GLStyledText implements SimpleRenderable { private static final long serialVersionUID = 1L; private static final boolean DEBUG = false; /** The text to display, with formatting commands in curly brackets eg. {font:big.glfont}HELLO */ private String text; /** The strings of text: a List of StyledText */ private final List<StyledText> strings = new ArrayList<StyledText>(); /** The lines of text: a List of StyledLines */ private final List<StyledLine> lines = new ArrayList<StyledLine>(); /** The size and location of the text area */ private final Rectangle bounds = new Rectangle(); /** The calculated text height - may be larger than that given by bounds */ private int textHeight; /** The number of actual glyphs */ private int numGlyphs; /** The text alignment */ private HorizontalAlignment horizontalAlignment = LEFT; /** Vertical alignment */ private VerticalAlignment verticalAlignment = TOP; /** Justified text */ private boolean justified; /** Changed flag: if the text changes etc. this is tripped */ private boolean changed = true; /** Blend colour */ private ReadableColor color = Color.WHITE; /** Alpha */ private int alpha = 255; /** Leading */ private int leading; /** String factory: takes the text, parses it, and creates runs of StyledText */ private StyledTextFactory factory; private int penY; private StyledWord currentWord = null; private final ArrayList<StyledWord> currentWords = new ArrayList<StyledWord>(); private StyledLine currentLine = null; private StyledText currentStyle = null; /** * StyledText represents text in a particular colour and font. */ public interface StyledText extends SimpleRenderable { /** * Get the top color of the text * @return a ReadableColor; never null */ ReadableColor getTopColor(); /** * Get teh bottom color of the text * @return a ReadableColor; never null */ ReadableColor getBottomColor(); /** * Get the font for the text * @return a GLFont; never null */ GLFont getFont(); /** * Get the text * @return a String; never null, but may be empty */ String getText(); /** * Add a thing to render * @param renderable */ void add(SimpleRenderable renderable); } public interface StyledTextFactory { /** * Parse the incoming text, and generate a List of StyledText. * @param text The raw text; may not be null * @param dest Destination list; may not be null; will be cleared first */ void parse(String text, List<StyledText> dest); } /** * The default styled text factory */ public static class DefaultStyledTextFactory implements StyledTextFactory { private ReadableColor topColor, bottomColor; private GLFont font; public DefaultStyledTextFactory(ReadableColor topColor, ReadableColor bottomColor, GLFont font) { this.topColor = topColor; this.bottomColor = bottomColor; this.font = font; } @Override public void parse(String text, List<StyledText> dest) { dest.clear(); // start parsing. Better get a { first! StringBuilder sb = new StringBuilder(text.length()); int n = text.length(); for (int i = 0; i < n; ) { char c = text.charAt(i); if (c == '{') { if (sb.length() > 0) { DefaultStyledText dst = new DefaultStyledText(sb.toString(), font, topColor, bottomColor); dest.add(dst); sb = new StringBuilder(text.length() - i); } i += parse(text, i + 1); } else { sb.append(c); i ++; } } if (sb.length() > 0) { DefaultStyledText dst = new DefaultStyledText(sb.toString(), font, topColor, bottomColor); dest.add(dst); } } private int parse(String text, int pos) { // Find index of } int idx = text.indexOf('}', pos); // Get substring String format = text.substring(pos, idx); // Split into tokens separated by spaces StringTokenizer st = new StringTokenizer(format, " "); // Parse each token, a key:value pair while (st.hasMoreTokens()) { String token = st.nextToken(); int colon = token.indexOf(':'); if (colon == -1) { if (DEBUG) { System.out.println("Bad token : "+token); } } else { String key = token.substring(0, colon).toLowerCase(); String value = token.substring(colon + 1); if (key.equals("top")) { topColor = new MappedColor(value); } else if (key.equals("bottom")) { bottomColor = new MappedColor(value); } else if (key.equals("color")) { topColor = new MappedColor(value); bottomColor = topColor; } else if (key.equals("font")) { font = (GLFont) Resources.get(value); } else { if (DEBUG) { System.out.println("Bad key : "+key); } } } } return idx - pos + 2; } } /** * The simple styled text factory, which doesn't do any styling except apply a font and uniform color to all the text */ public static class SimpleStyledTextFactory implements StyledTextFactory { private final ReadableColor color; private final GLFont font; public SimpleStyledTextFactory(ReadableColor color, GLFont font) { this.color = color; this.font = font; } @Override public void parse(String text, List<StyledText> dest) { dest.clear(); DefaultStyledText dst = new DefaultStyledText(text, font, color, color); dest.add(dst); } } /** * Default styled text implementation */ public static class DefaultStyledText implements StyledText { private final ReadableColor bottomColor, topColor; private final GLFont font; private final String text; private final ArrayList<SimpleRenderable> renderables = new ArrayList<SimpleRenderable>(); public DefaultStyledText(String text, GLFont font, ReadableColor topColor, ReadableColor bottomColor) { this.text = text; this.font = font; this.topColor = topColor; this.bottomColor = bottomColor; } @Override public ReadableColor getBottomColor() { return bottomColor; } @Override public GLFont getFont() { return font; } @Override public String getText() { return text; } @Override public ReadableColor getTopColor() { return topColor; } @Override public void add(SimpleRenderable renderable) { renderables.add(renderable); } @Override public void render(SimpleRenderer renderer) { int n = renderables.size(); if (n == 0) { return; } renderer.glRender(new GLRenderable() { @Override public void render() { font.render(); } }); for (int i = 0; i < n; i ++) { SimpleRenderable renderable = renderables.get(i); renderable.render(renderer); } } } /** * A StyledWord is a sequence of GLGlyphs associated with a StyledText. It optionally * has a wordBreak at the end of it, allowing spaces to be expanded. Initially the origin for a StyledWorld is 0,0 */ private class StyledWord implements SimpleRenderable { private final ArrayList<GLGlyph> glyphs = new ArrayList<GLGlyph>(); private final StyledText style; /** Scaled metrics */ private int ascent, descent, width; /** Gap at the end of the word, if we're not the last word on the line */ private int gap; private GLGlyph lastGlyph; private final Color topColor = new Color(); private final Color bottomColor = new Color(); StyledWord(StyledText style) { this.style = style; ascent = style.getFont().getAscent(); descent = style.getFont().getDescent(); } @Override public void render(SimpleRenderer renderer) { int n = glyphs.size(); if (n == 0) { return; } if (style.getTopColor() != null) { ColorUtil.blendColor(style.getTopColor(), color, topColor); ColorUtil.blendColor(style.getBottomColor(), color, bottomColor); } for (int i = 0; i < n; i ++) { glyphs.get(i).render(topColor, bottomColor, alpha, renderer); } } void addGlyph(GLGlyph glyph) { glyphs.add(glyph); glyph.setLocation(width + glyph.getBearingX(), glyph.getBearingY()); width += glyph.getAdvance(); // Maybe kern with last glyph if (lastGlyph != null) { width += glyph.getKerningAfter(lastGlyph); } lastGlyph = glyph; } void layout(int x, int y) { int n = glyphs.size(); for (int i = 0; i < n; i ++) { GLGlyph glyph = glyphs.get(i); glyph.setLocation(glyph.getXpos() + x, glyph.getYpos() + y); } } int calcGap() { return style.getFont().map(' ').getAdvance(); } void addGap() { gap = calcGap(); } @Override public String toString() { return "StyledWord["+style+", "+glyphs+"]"; } } /** * A StyledLine is a List of StyledWords. Initially all at y coordinate 0 until laid out. */ private class StyledLine { private final ArrayList<StyledWord> words = new ArrayList<StyledWord>(); /** The ascent and descent of the line (no leading) */ private int ascent, descent; /** Total width */ private int width; /** Current font */ GLFont currentFont; private boolean paragraphBreak; private StyledWord lastWord; StyledLine(GLFont currentFont) { this.currentFont = currentFont; } /** * Check if this list of unbroken words fits * @param newWords * @return true if they all fit */ boolean fits(ArrayList<StyledWord> newWords) { if (lastWord == null) { // No words on this line yet - so always allow a fit return true; } int testWidth = width; int n = newWords.size(); for (int i = 0; i < n; i ++) { testWidth += (newWords.get(i)).width; } if (lastWord != null) { testWidth += lastWord.calcGap(); } return testWidth <= bounds.getWidth(); } @Override public String toString() { return "StyledLine["+width+", "+ascent+", "+descent+", "+paragraphBreak+", words:" + words+"]"; } void addWords(List<StyledWord> newWords) { words.addAll(newWords); int n = newWords.size(); for (int i = 0; i < n; i ++) { StyledWord word = newWords.get(i); ascent = Math.max(ascent, word.ascent); descent = Math.max(descent, word.descent); width += word.width; } if (lastWord != null) { // Add a gap from the last word lastWord.addGap(); width += lastWord.gap; } lastWord = newWords.get(n - 1); } void layout(int y) { int n = words.size(); if (n == 0) { return; } int x = bounds.getX(); if (justified && !paragraphBreak && n != 1) { // Spread the available whitespace out amongst the words. int availableWidth = bounds.getWidth() - width; int spread = availableWidth / (words.size() - 1); int remainder = availableWidth % (words.size() - 1); for (int i = 0; i < n; i ++) { StyledWord word = words.get(i); word.gap += spread; if (i < remainder) { word.gap ++; } word.layout(x, y); x += word.width + word.gap; } return; } if (horizontalAlignment == RIGHT) { // Move to right x += bounds.getWidth() - width; } else if (horizontalAlignment == CENTERED) { // Move to middle x += bounds.getWidth() - width >> 1; } for (int i = 0; i < n; i ++) { StyledWord word = words.get(i); word.layout(x, y); x += word.width + word.gap; } } } /** Horizontal alignments */ public abstract static class HorizontalAlignment implements Serializable, Decodeable { private static final long serialVersionUID = 1L; private final String display; private HorizontalAlignment(String display) { this.display = display; } @Override public String toString() { return display; } /** * Decode method, for Decodeable marker * @param in * @return * @throws Exception */ public static Object decode(String in) throws Exception { if (in.equalsIgnoreCase(LEFT.display)) { return LEFT; } else if (in.equalsIgnoreCase(RIGHT.display)) { return RIGHT; } else if (in.equalsIgnoreCase(CENTERED.display)) { return CENTERED; } else { throw new Exception("Unknown horizontal alignment '"+in+"'"); } } } public static final HorizontalAlignment LEFT = new HorizontalAlignment("Left") { private static final long serialVersionUID = 1L; private Object readResolve() throws ObjectStreamException { return LEFT; } }; public static final HorizontalAlignment RIGHT = new HorizontalAlignment("Right") { private static final long serialVersionUID = 1L; private Object readResolve() throws ObjectStreamException { return RIGHT; } }; public static final HorizontalAlignment CENTERED = new HorizontalAlignment("Centered") { private static final long serialVersionUID = 1L; private Object readResolve() throws ObjectStreamException { return CENTERED; } }; /** Vertical Alignments */ public abstract static class VerticalAlignment implements Serializable, Decodeable { private static final long serialVersionUID = 1L; private final String display; private VerticalAlignment(String display) { this.display = display; } @Override public String toString() { return display; } public static Object decode(String in) throws Exception { if (in.equalsIgnoreCase(TOP.display)) { return TOP; } else if (in.equalsIgnoreCase(BOTTOM.display)) { return BOTTOM; } else if (in.equalsIgnoreCase(VCENTERED.display)) { return VCENTERED; } else { throw new Exception("Unknown vertical alignment '"+in+"'"); } } } /** * The text is drawn flush with the top of the text area. */ public static final VerticalAlignment TOP = new VerticalAlignment("Top") { private static final long serialVersionUID = 1L; private Object readResolve() throws ObjectStreamException { return TOP; } }; /** * The text is drawn flush with the bottom of the text area. */ public static final VerticalAlignment BOTTOM = new VerticalAlignment("Bottom") { private static final long serialVersionUID = 1L; private Object readResolve() throws ObjectStreamException { return BOTTOM; } }; /** * Vertically centres the text in the textarea's bounds */ public static final VerticalAlignment VCENTERED = new VerticalAlignment("Centered") { private static final long serialVersionUID = 1L; private Object readResolve() throws ObjectStreamException { return VCENTERED; } }; /** * GLTextArea constructor comment. */ public GLStyledText() { } /** * @param leading the leading to set */ public void setLeading(int leading) { this.leading = leading; changed = true; } /** * @return the leading */ public int getLeading() { return leading; } private void endWord(boolean nextLine, boolean wordBreak) { if (currentStyle == null) { return; } if (currentWord != null) { currentStyle.add(currentWord); } if (currentLine == null) { currentLine = new StyledLine(currentStyle.getFont()); } if (!currentLine.fits(currentWords)) { // Start a new line. lines.add(currentLine); currentLine = new StyledLine(currentStyle.getFont()); } if (wordBreak || nextLine) { // Add current words to the line if (currentWords.size() == 0) { } else { currentLine.addWords(currentWords); currentWords.clear(); } } currentWord = null; if (nextLine) { lines.add(currentLine); currentLine = new StyledLine(currentStyle.getFont()); } } /** * Performs a layout of the text. This should be performed if the text or font is changed or the size of * the text area is adjusted. */ private void layout() { if (!changed) { return; } // Reset everything changed = false; textHeight = 0; numGlyphs = 0; strings.clear(); lines.clear(); // Parse the text into StyledTexts if (factory == null) { // There's no factory if (DEBUG) { System.out.println("GLStyledText has no factory! "+text); } return; } if (text == null) { return; } // Parse the text into a bunch of StyledText factory.parse(text, strings); // Parse the StyledText into StyledWords and StyledLines int numStrings = strings.size(); penY = 0; for (int i = 0; i < numStrings; i ++) { currentStyle = strings.get(i); int length = currentStyle.getText().length(); for (int j = 0; j < length; j ++) { char c = currentStyle.getText().charAt(j); if (c == '\n') { // End current word and go to the next line. endWord(true, true); } else if (c == ' ') { // End current word. endWord(false, true); } else { // Append glyph to current word. if (currentWord == null) { currentWord = new StyledWord(currentStyle); currentWords.add(currentWord); } currentWord.addGlyph(currentStyle.getFont().map(c)); } } endWord(false, false); } endWord(true, true); currentWord = null; currentLine = null; currentStyle = null; currentWords.clear(); // Calculate height of the text now. int numLines = lines.size(); if (numLines == 0) { return; } for (int i = 0; i < numLines; i ++) { StyledLine line = lines.get(i); if (line.lastWord == null) { line.ascent = line.currentFont.getAscent(); line.descent = line.currentFont.getDescent(); } textHeight += line.ascent + line.descent; if (i < numLines - 1) { textHeight += leading; } } int firstLineAscent = (lines.get(0)).ascent; // Now align to given box. Currently the glyphs baseline is at 0,0 and they stretch downwards into negative // coordinates. If TOP aligned then need shifting up by -penY. If BOTTOM aligned then they need shifting up // by the specified height minus penY. final int ty; if (verticalAlignment == TOP) { // Translate all glyphs up ty = bounds.getHeight() - firstLineAscent; } else if (verticalAlignment == VCENTERED) { // Translate all glyphs up ty = textHeight + (bounds.getHeight() - textHeight) / 2 - firstLineAscent; } else { // Translate all glyphs up ty = textHeight - firstLineAscent; } penY = 0; for (int i = 0; i < numLines; i ++) { StyledLine line = lines.get(i); line.layout(bounds.getY() + ty - penY); penY += line.descent + leading; if (i < numLines - 1) { StyledLine nextLine = lines.get(i + 1); penY += nextLine.ascent; } } } @Override public void render(SimpleRenderer renderer) { layout(); if (strings.size() == 0) { return; } // Setup state renderer.glRender(new GLRenderable() { @Override public void render() { glEnable(GL_TEXTURE_2D); glEnable(GL_BLEND); glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA); glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_MODULATE); } }); // Render each renderable in turn int n = strings.size(); for (int i = 0; i < n; i ++) { SimpleRenderable renderable = strings.get(i); renderable.render(renderer); } } /** * @return the numGlyphs */ public int getNumGlyphs() { return numGlyphs; } /** * Set the horizontal alignment */ public void setHorizontalAlignment(HorizontalAlignment alignment) { if (this.horizontalAlignment == alignment) { return; } this.horizontalAlignment = alignment; changed = true; } /** * Set the vertical alignement */ public void setVerticalAlignment(VerticalAlignment valignment) { if (this.verticalAlignment == valignment) { return; } this.verticalAlignment = valignment; changed = true; } /** * Returns the calculated text height. You can use this to resize the * text area if you like. */ public int getTextHeight() { layout(); return textHeight; } /** * @return */ public int getX() { return bounds.getX(); } /** * @return */ public int getY() { return bounds.getY(); } /** * @param x * @param y */ public void setLocation(int x, int y) { if (bounds.getX() == x && bounds.getY() == y) { return; } bounds.setLocation(x, y); changed = true; } /** * @param p */ public void setLocation(ReadablePoint p) { setLocation(p.getX(), p.getY()); } /** * @param x */ public void setX(int x) { if (bounds.getX() == x) { return; } bounds.setX(x); changed = true; } /** * @param y */ public void setY(int y) { if (bounds.getY() == y) { return; } bounds.setY(y); changed = true; } /** * @return the height of the text bounds */ public int getHeight() { return bounds.getHeight(); } /** * @return the width of the text bounds */ public int getWidth() { return bounds.getWidth(); } public void setHeight(int height) { if (bounds.getHeight() == height) { return; } changed = true; bounds.setHeight(height); } public void setWidth(int width) { if (bounds.getWidth() == width) { return; } changed = true; bounds.setWidth(width); } public void setBounds(int x, int y, int w, int h) { setLocation(x, y); setSize(w, h); } public void setBounds(ReadableRectangle r) { setLocation(r); setSize(r); } public void setSize(int w, int h) { if (bounds.getWidth() == w && bounds.getHeight() == h) { return; } changed = true; bounds.setSize(w, h); } public void setSize(ReadableDimension d) { setSize(d.getWidth(), d.getHeight()); } public VerticalAlignment getVerticalAlignment() { return verticalAlignment; } public HorizontalAlignment getHorizontalAlignment() { return horizontalAlignment; } /** * Set the text * @param newText, may be null */ public void setText(String newText) { if (newText != text) { text = newText; changed = true; } } /** * @return the RAW text (with all its formatting); may be null */ public String getText() { return text; } public void setAlpha(int alpha) { this.alpha = alpha; } public void setFactory(StyledTextFactory factory) { if (this.factory != factory) { this.factory = factory; changed = true; } } public StyledTextFactory getFactory() { return factory; } public void setJustified(boolean justified) { if (this.justified != justified) { this.justified = justified; changed = true; } } public boolean isJustified() { return justified; } /** * Force layout or not layout * @param changed */ public void setChanged(boolean changed) { this.changed = changed; } // chaz hack! public int getNumLines() { layout(); return lines.size(); } public void setColor(ReadableColor color) { this.color = color; } public ReadableColor getColor() { return color; } }