package com.rafaskoberg.gdx.typinglabel;

import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.graphics.g2d.Batch;
import com.badlogic.gdx.graphics.g2d.BitmapFont;
import com.badlogic.gdx.graphics.g2d.BitmapFont.Glyph;
import com.badlogic.gdx.graphics.g2d.BitmapFontCache;
import com.badlogic.gdx.graphics.g2d.GlyphLayout;
import com.badlogic.gdx.graphics.g2d.GlyphLayout.GlyphRun;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.scenes.scene2d.ui.Label;
import com.badlogic.gdx.scenes.scene2d.ui.Skin;
import com.badlogic.gdx.scenes.scene2d.utils.Drawable;
import com.badlogic.gdx.utils.Align;
import com.badlogic.gdx.utils.Array;
import com.badlogic.gdx.utils.IntArray;
import com.badlogic.gdx.utils.ObjectMap;
import com.badlogic.gdx.utils.ObjectMap.Entry;
import com.badlogic.gdx.utils.StringBuilder;
import com.badlogic.gdx.utils.reflect.ClassReflection;

/**
 * An extension of {@link Label} that progressively shows the text as if it was being typed in real time, and allows the
 * use of tokens in the following format: <tt>{TOKEN=PARAMETER}</tt>.
 */
public class TypingLabel extends Label {
    ///////////////////////
    /// --- Members --- ///
    ///////////////////////

    // Collections
    private final   ObjectMap<String, String> variables    = new ObjectMap<String, String>();
    protected final Array<TokenEntry>         tokenEntries = new Array<TokenEntry>();

    // Config
    private Color          clearColor = new Color(TypingConfig.DEFAULT_CLEAR_COLOR);
    private TypingListener listener   = null;
    boolean forceMarkupColor = TypingConfig.FORCE_COLOR_MARKUP_BY_DEFAULT;

    // Internal state
    private final StringBuilder      originalText          = new StringBuilder();
    private final Array<TypingGlyph> glyphCache            = new Array<TypingGlyph>();
    private final IntArray           glyphRunCapacities    = new IntArray();
    private final IntArray           offsetCache           = new IntArray();
    private final IntArray           layoutLineBreaks      = new IntArray();
    private final Array<Effect>      activeEffects         = new Array<Effect>();
    private       float              textSpeed             = TypingConfig.DEFAULT_SPEED_PER_CHAR;
    private       float              charCooldown          = textSpeed;
    private       int                rawCharIndex          = -2; // All chars, including color codes
    private       int                glyphCharIndex        = -1; // Only renderable chars, excludes color codes
    private       int                glyphCharCompensation = 0;
    private       int                cachedGlyphCharIndex  = -1; // Last glyphCharIndex sent to the cache
    private       float              lastLayoutX           = 0;
    private       float              lastLayoutY           = 0;
    private       boolean            parsed                = false;
    private       boolean            paused                = false;
    private       boolean            ended                 = false;
    private       boolean            skipping              = false;
    private       boolean            ignoringEvents        = false;
    private       boolean            ignoringEffects       = false;
    private       String             defaultToken          = "";

    // Superclass mirroring
    boolean wrap;
    String  ellipsis;
    float   lastPrefHeight;
    boolean fontScaleChanged = false;

    ////////////////////////////
    /// --- Constructors --- ///
    ////////////////////////////

    public TypingLabel(CharSequence text, LabelStyle style) {
        super(text, style);
        saveOriginalText();
    }

    public TypingLabel(CharSequence text, Skin skin, String fontName, Color color) {
        super(text, skin, fontName, color);
        saveOriginalText();
    }

    public TypingLabel(CharSequence text, Skin skin, String fontName, String colorName) {
        super(text, skin, fontName, colorName);
        saveOriginalText();
    }

    public TypingLabel(CharSequence text, Skin skin, String styleName) {
        super(text, skin, styleName);
        saveOriginalText();
    }

    public TypingLabel(CharSequence text, Skin skin) {
        super(text, skin);
        saveOriginalText();
    }

    /////////////////////////////
    /// --- Text Handling --- ///
    /////////////////////////////

    /**
     * Modifies the text of this label. If the char progression is already running, it's highly recommended to use
     * {@link #restart(CharSequence)} instead.
     */
    @Override
    public void setText(CharSequence newText) {
        this.setText(newText, true);
    }

    /**
     * Sets the text of this label.
     *
     * @param modifyOriginalText Flag determining if the original text should be modified as well. If {@code false},
     *                           only the display text is changed while the original text is untouched.
     * @see #restart(CharSequence)
     */
    protected void setText(CharSequence newText, boolean modifyOriginalText) {
        setText(newText, modifyOriginalText, true);
    }

    /**
     * Sets the text of this label.
     *
     * @param modifyOriginalText Flag determining if the original text should be modified as well. If {@code false},
     *                           only the display text is changed while the original text is untouched.
     * @param restart            Whether or not this label should restart. Defaults to true.
     * @see #restart(CharSequence)
     */
    protected void setText(CharSequence newText, boolean modifyOriginalText, boolean restart) {
        final boolean hasEnded = this.hasEnded();
        super.setText(newText);
        if(modifyOriginalText) saveOriginalText();
        if(restart) {
            this.restart();
        }
        if(hasEnded) {
            this.skipToTheEnd(true, false);
        }
    }

    /** Similar to {@link #getText()}, but returns the original text with all the tokens unchanged. */
    public StringBuilder getOriginalText() {
        return originalText;
    }

    /**
     * Copies the content of {@link #getText()} to the {@link StringBuilder} containing the original text with all
     * tokens unchanged.
     */
    protected void saveOriginalText() {
        originalText.setLength(0);
        originalText.insert(0, this.getText());
        originalText.trimToSize();
    }

    /**
     * Restores the original text with all tokens unchanged to this label. Make sure to call {@link #parseTokens()} to
     * parse the tokens again.
     */
    protected void restoreOriginalText() {
        super.setText(originalText);
        this.parsed = false;
    }

    ////////////////////////////
    /// --- External API --- ///
    ////////////////////////////

    /** Returns the {@link TypingListener} associated with this label. May be {@code null}. */
    public TypingListener getTypingListener() {
        return listener;
    }

    /** Sets the {@link TypingListener} associated with this label, or {@code null} to remove the current one. */
    public void setTypingListener(TypingListener listener) {
        this.listener = listener;
    }

    /**
     * Returns a {@link Color} instance with the color to be used on {@code CLEARCOLOR} tokens. Modify this instance to
     * change the token color. Default value is specified by {@link TypingConfig}.
     *
     * @see TypingConfig#DEFAULT_CLEAR_COLOR
     */
    public Color getClearColor() {
        return clearColor;
    }

    /**
     * Sets whether or not this instance should enable markup color by force.
     *
     * @see TypingConfig#FORCE_COLOR_MARKUP_BY_DEFAULT
     */
    public void setForceMarkupColor(boolean forceMarkupColor) {
        this.forceMarkupColor = forceMarkupColor;
    }

    /** Returns the default token being used in this label. Defaults to empty string. */
    public String getDefaultToken() {
        return defaultToken;
    }

    /**
     * Sets the default token being used in this label. This token will be used before the label's text, and after each
     * {RESET} call. Useful if you want a certain token to be active at all times without having to type it all the
     * time.
     */
    public void setDefaultToken(String defaultToken) {
        this.defaultToken = defaultToken == null ? "" : defaultToken;
        this.parsed = false;
    }

    /** Parses all tokens of this label. Use this after setting the text and any variables that should be replaced. */
    public void parseTokens() {
        this.setText(getDefaultToken() + getText(), false, false);
        Parser.parseTokens(this);
        parsed = true;
    }

    /**
     * Skips the char progression to the end, showing the entire label. Useful for when users don't want to wait for too
     * long. Ignores all subsequent events by default.
     */
    public void skipToTheEnd() {
        skipToTheEnd(true);
    }

    /**
     * Skips the char progression to the end, showing the entire label. Useful for when users don't want to wait for too
     * long.
     *
     * @param ignoreEvents If {@code true}, skipped events won't be reported to the listener.
     */
    public void skipToTheEnd(boolean ignoreEvents) {
        skipToTheEnd(ignoreEvents, false);
    }

    /**
     * Skips the char progression to the end, showing the entire label. Useful for when users don't want to wait for too
     * long.
     *
     * @param ignoreEvents  If {@code true}, skipped events won't be reported to the listener.
     * @param ignoreEffects If {@code true}, all text effects will be instantly cancelled.
     */
    public void skipToTheEnd(boolean ignoreEvents, boolean ignoreEffects) {
        skipping = true;
        ignoringEvents = ignoreEvents;
        ignoringEffects = ignoreEffects;
    }

    /**
     * Cancels calls to {@link #skipToTheEnd()}. Useful if you need to restore the label's normal behavior at some event
     * after skipping.
     */
    public void cancelSkipping() {
        if(skipping) {
            skipping = false;
            ignoringEvents = false;
            ignoringEffects = false;
        }
    }

    /**
     * Returns whether or not this label is currently skipping its typing progression all the way to the end. This is
     * only true if skipToTheEnd is called.
     */
    public boolean isSkipping() {
        return skipping;
    }

    /** Returns whether or not this label is paused. */
    public boolean isPaused() {
        return paused;
    }

    /** Pauses this label's character progression. */
    public void pause() {
        paused = true;
    }

    /** Resumes this label's character progression. */
    public void resume() {
        paused = false;
    }

    /** Returns whether or not this label's char progression has ended. */
    public boolean hasEnded() {
        return ended;
    }

    /**
     * Restarts this label with the original text and starts the char progression right away. All tokens are
     * automatically parsed.
     */
    public void restart() {
        restart(getOriginalText());
    }

    /**
     * Restarts this label with the given text and starts the char progression right away. All tokens are automatically
     * parsed.
     */
    public void restart(CharSequence newText) {
        // Reset cache collections
        GlyphUtils.freeAll(glyphCache);
        glyphCache.clear();
        glyphRunCapacities.clear();
        offsetCache.clear();
        layoutLineBreaks.clear();
        activeEffects.clear();

        // Reset state
        textSpeed = TypingConfig.DEFAULT_SPEED_PER_CHAR;
        charCooldown = textSpeed;
        rawCharIndex = -2;
        glyphCharIndex = -1;
        glyphCharCompensation = 0;
        cachedGlyphCharIndex = -1;
        lastLayoutX = 0;
        lastLayoutY = 0;
        parsed = false;
        paused = false;
        ended = false;
        skipping = false;
        ignoringEvents = false;
        ignoringEffects = false;

        // Set new text
        this.setText(newText, true, false);
        invalidate();

        // Parse tokens
        tokenEntries.clear();
        parseTokens();
    }

    /** Returns an {@link ObjectMap} with all the variable names and their respective replacement values. */
    public ObjectMap<String, String> getVariables() {
        return variables;
    }

    /** Registers a variable and its respective replacement value to this label. */
    public void setVariable(String var, String value) {
        variables.put(var.toUpperCase(), value);
    }

    /** Registers a set of variables and their respective replacement values to this label. */
    public void setVariables(ObjectMap<String, String> variableMap) {
        this.variables.clear();
        for(Entry<String, String> entry : variableMap.entries()) {
            this.variables.put(entry.key.toUpperCase(), entry.value);
        }
    }

    /** Registers a set of variables and their respective replacement values to this label. */
    public void setVariables(java.util.Map<String, String> variableMap) {
        this.variables.clear();
        for(java.util.Map.Entry<String, String> entry : variableMap.entrySet()) {
            this.variables.put(entry.getKey().toUpperCase(), entry.getValue());
        }
    }

    /** Removes all variables from this label. */
    public void clearVariables() {
        this.variables.clear();
    }

    //////////////////////////////////
    /// --- Core Functionality --- ///
    //////////////////////////////////

    @Override
    public void act(float delta) {
        super.act(delta);

        // Force token parsing
        if(!parsed) {
            parseTokens();
        }

        // Update cooldown and process char progression
        if(skipping || (!ended && !paused)) {
            if(skipping || (charCooldown -= delta) < 0.0f) {
                processCharProgression();
            }
        }

        // Restore glyph offsets
        if(activeEffects.size > 0) {
            for(int i = 0; i < glyphCache.size; i++) {
                TypingGlyph glyph = glyphCache.get(i);
                glyph.xoffset = offsetCache.get(i * 2);
                glyph.yoffset = offsetCache.get(i * 2 + 1);
            }
        }

        // Apply effects
        if(!ignoringEffects) {
            for(int i = activeEffects.size - 1; i >= 0; i--) {
                Effect effect = activeEffects.get(i);
                effect.update(delta);
                int start = effect.indexStart;
                int end = effect.indexEnd >= 0 ? effect.indexEnd : glyphCharIndex;

                // If effect is finished, remove it
                if(effect.isFinished()) {
                    activeEffects.removeIndex(i);
                    continue;
                }

                // Apply effect to glyph
                for(int j = Math.max(0, start); j <= glyphCharIndex && j <= end && j < glyphCache.size; j++) {
                    TypingGlyph glyph = glyphCache.get(j);
                    effect.apply(glyph, j, delta);
                }
            }
        }
    }

    /** Proccess char progression according to current cooldown and process all tokens in the current index. */
    private void processCharProgression() {
        // Keep a counter of how many chars we're processing in this tick.
        int charCounter = 0;

        // Process chars while there's room for it
        while(skipping || charCooldown < 0.0f) {
            // Apply compensation to glyph index, if any
            if(glyphCharCompensation != 0) {
                if(glyphCharCompensation > 0) {
                    glyphCharIndex++;
                    glyphCharCompensation--;
                } else {
                    glyphCharIndex--;
                    glyphCharCompensation++;
                }

                // Increment cooldown and wait for it
                charCooldown += textSpeed;
                continue;
            }

            // Increase raw char index
            rawCharIndex++;

            // Get next character and calculate cooldown increment
            int safeIndex = MathUtils.clamp(rawCharIndex, 0, getText().length - 1);
            char primitiveChar = '\u0000'; // Null character by default
            if(getText().length > 0) {
                primitiveChar = getText().charAt(safeIndex);
                Character ch = Character.valueOf(primitiveChar);
                float intervalMultiplier = TypingConfig.INTERVAL_MULTIPLIERS_BY_CHAR.get(ch, 1);
                charCooldown += textSpeed * intervalMultiplier;
            }

            // If char progression is finished, or if text is empty, notify listener and abort routine
            int textLen = getText().length;
            if(textLen == 0 || rawCharIndex >= textLen) {
                if(!ended) {
                    ended = true;
                    skipping = false;
                    if(listener != null) listener.end();
                }
                return;
            }

            // Detect layout line breaks
            boolean isLayoutLineBreak = false;
            if(layoutLineBreaks.contains(glyphCharIndex)) {
                layoutLineBreaks.removeValue(glyphCharIndex);
                isLayoutLineBreak = true;
            }

            // Increase glyph char index for all characters, except new lines.
            if(rawCharIndex >= 0 && primitiveChar != '\n' && !isLayoutLineBreak) glyphCharIndex++;

            // Process tokens according to the current index
            while(tokenEntries.size > 0 && tokenEntries.peek().index == rawCharIndex) {
                TokenEntry entry = tokenEntries.pop();
                String token = entry.token;
                TokenCategory category = entry.category;

                // Process tokens
                switch(category) {
                    case SPEED: {
                        textSpeed = entry.floatValue;
                        continue;
                    }
                    case WAIT: {
                        glyphCharIndex--;
                        glyphCharCompensation++;
                        charCooldown += entry.floatValue;
                        continue;
                    }
                    case SKIP: {
                        if(entry.stringValue != null) {
                            rawCharIndex += entry.stringValue.length();
                        }
                        continue;
                    }
                    case EVENT: {
                        if(this.listener != null && !ignoringEvents) {
                            listener.event(entry.stringValue);
                        }
                        continue;
                    }
                    case EFFECT_START:
                    case EFFECT_END: {
                        // Get effect class
                        boolean isStart = category == TokenCategory.EFFECT_START;
                        Class<? extends Effect> effectClass = isStart ? TypingConfig.EFFECT_START_TOKENS.get(token) : TypingConfig.EFFECT_END_TOKENS.get(token);

                        // End all effects of the same type
                        for(int i = 0; i < activeEffects.size; i++) {
                            Effect effect = activeEffects.get(i);
                            if(effect.indexEnd < 0) {
                                if(ClassReflection.isAssignableFrom(effectClass, effect.getClass())) {
                                    effect.indexEnd = glyphCharIndex - 1;
                                }
                            }
                        }

                        // Create new effect if necessary
                        if(isStart) {
                            entry.effect.indexStart = glyphCharIndex;
                            activeEffects.add(entry.effect);
                        }

                        continue;
                    }
                }
            }

            // Notify listener about char progression
            int nextIndex = rawCharIndex == 0 ? 0 : MathUtils.clamp(rawCharIndex, 0, getText().length - 1);
            Character nextChar = nextIndex == 0 ? null : getText().charAt(nextIndex);
            if(nextChar != null && listener != null) {
                listener.onChar(nextChar);
            }

            // Increment char counter
            charCounter++;

            // Break loop if this was our first glyph to prevent glyph issues.
            if(glyphCharIndex == -1) {
                charCooldown = textSpeed;
                break;
            }

            // Break loop if enough chars were processed
            charCounter++;
            int charLimit = TypingConfig.CHAR_LIMIT_PER_FRAME;
            if(!skipping && charLimit > 0 && charCounter > charLimit) {
                charCooldown = Math.max(charCooldown, textSpeed);
                break;
            }
        }
    }

    @Override
    public boolean remove() {
        GlyphUtils.freeAll(glyphCache);
        glyphCache.clear();
        return super.remove();
    }

    ////////////////////////////////////
    /// --- Superclass Mirroring --- ///
    ////////////////////////////////////

    @Override
    public BitmapFontCache getBitmapFontCache() {
        return super.getBitmapFontCache();
    }

    @Override
    public void setEllipsis(String ellipsis) {
        // Mimics superclass but keeps an accessible reference
        super.setEllipsis(ellipsis);
        this.ellipsis = ellipsis;
    }

    @Override
    public void setEllipsis(boolean ellipsis) {
        // Mimics superclass but keeps an accessible reference
        super.setEllipsis(ellipsis);
        if(ellipsis)
            this.ellipsis = "...";
        else
            this.ellipsis = null;
    }

    @Override
    public void setWrap(boolean wrap) {
        // Mimics superclass but keeps an accessible reference
        super.setWrap(wrap);
        this.wrap = wrap;
    }

    @Override
    public void setFontScale(float fontScale) {
        super.setFontScale(fontScale);
        this.fontScaleChanged = true;
    }

    @Override
    public void setFontScale(float fontScaleX, float fontScaleY) {
        super.setFontScale(fontScaleX, fontScaleY);
        this.fontScaleChanged = true;
    }

    @Override
    public void setFontScaleX(float fontScaleX) {
        super.setFontScaleX(fontScaleX);
        this.fontScaleChanged = true;
    }

    @Override
    public void setFontScaleY(float fontScaleY) {
        super.setFontScaleY(fontScaleY);
        this.fontScaleChanged = true;
    }

    @Override
    public void layout() {
        // --- SUPERCLASS IMPLEMENTATION (but with accessible getters instead) ---
        BitmapFontCache cache = getBitmapFontCache();
        StringBuilder text = getText();
        GlyphLayout layout = super.getGlyphLayout();
        int lineAlign = getLineAlign();
        int labelAlign = getLabelAlign();
        LabelStyle style = getStyle();

        BitmapFont font = cache.getFont();
        float oldScaleX = font.getScaleX();
        float oldScaleY = font.getScaleY();
        if(fontScaleChanged) font.getData().setScale(getFontScaleX(), getFontScaleY());

        boolean wrap = this.wrap && ellipsis == null;
        if(wrap) {
            float prefHeight = getPrefHeight();
            if(prefHeight != lastPrefHeight) {
                lastPrefHeight = prefHeight;
                invalidateHierarchy();
            }
        }

        float width = getWidth(), height = getHeight();
        Drawable background = style.background;
        float x = 0, y = 0;
        if(background != null) {
            x = background.getLeftWidth();
            y = background.getBottomHeight();
            width -= background.getLeftWidth() + background.getRightWidth();
            height -= background.getBottomHeight() + background.getTopHeight();
        }

        float textWidth, textHeight;
        if(wrap || text.indexOf("\n") != -1) {
            // If the text can span multiple lines, determine the text's actual size so it can be aligned within the label.
            layout.setText(font, text, 0, text.length, Color.WHITE, width, lineAlign, wrap, ellipsis);
            textWidth = layout.width;
            textHeight = layout.height;

            if((labelAlign & Align.left) == 0) {
                if((labelAlign & Align.right) != 0)
                    x += width - textWidth;
                else
                    x += (width - textWidth) / 2;
            }
        } else {
            textWidth = width;
            textHeight = font.getData().capHeight;
        }

        if((labelAlign & Align.top) != 0) {
            y += cache.getFont().isFlipped() ? 0 : height - textHeight;
            y += style.font.getDescent();
        } else if((labelAlign & Align.bottom) != 0) {
            y += cache.getFont().isFlipped() ? height - textHeight : 0;
            y -= style.font.getDescent();
        } else {
            y += (height - textHeight) / 2;
        }
        if(!cache.getFont().isFlipped()) y += textHeight;

        layout.setText(font, text, 0, text.length, Color.WHITE, textWidth, lineAlign, wrap, ellipsis);
        cache.setText(layout, x, y);

        if(fontScaleChanged) font.getData().setScale(oldScaleX, oldScaleY);

        // --- END OF SUPERCLASS IMPLEMENTATION ---

        // Store coordinates passed to BitmapFontCache
        lastLayoutX = x;
        lastLayoutY = y;

        // Perform cache layout operation, where the magic happens
        GlyphUtils.freeAll(glyphCache);
        glyphCache.clear();
        layoutCache();
    }

    /**
     * Reallocate glyph clones according to the updated {@link GlyphLayout}. This should only be called when the text or
     * the layout changes.
     */
    private void layoutCache() {
        BitmapFontCache cache = getBitmapFontCache();
        GlyphLayout layout = super.getGlyphLayout();
        Array<GlyphRun> runs = layout.runs;

        // Reset layout line breaks
        layoutLineBreaks.clear();

        // Store GlyphRun sizes and count how many glyphs we have
        int glyphCount = 0;
        glyphRunCapacities.setSize(runs.size);
        for(int i = 0; i < runs.size; i++) {
            Array<Glyph> glyphs = runs.get(i).glyphs;
            glyphRunCapacities.set(i, glyphs.size);
            glyphCount += glyphs.size;
        }

        // Make sure our cache array can hold all glyphs
        if(glyphCache.size < glyphCount) {
            glyphCache.setSize(glyphCount);
            offsetCache.setSize(glyphCount * 2);
        }

        // Clone original glyphs with independent instances
        int index = -1;
        float lastY = 0;
        for(int i = 0; i < runs.size; i++) {
            GlyphRun run = runs.get(i);
            Array<Glyph> glyphs = run.glyphs;
            for(int j = 0; j < glyphs.size; j++) {

                // Detect and store layout line breaks
                if(!MathUtils.isEqual(run.y, lastY)) {
                    lastY = run.y;
                    layoutLineBreaks.add(index);
                }

                // Increment index
                index++;

                // Get original glyph
                Glyph original = glyphs.get(j);

                // Get clone glyph
                TypingGlyph clone = null;
                if(index < glyphCache.size) {
                    clone = glyphCache.get(index);
                }
                if(clone == null) {
                    clone = GlyphUtils.obtain();
                    glyphCache.set(index, clone);
                }
                GlyphUtils.clone(original, clone);
                clone.width *= getFontScaleX();
                clone.height *= getFontScaleY();
                clone.xoffset *= getFontScaleX();
                clone.yoffset *= getFontScaleY();
                clone.run = run;

                // Store offset data
                offsetCache.set(index * 2, clone.xoffset);
                offsetCache.set(index * 2 + 1, clone.yoffset);

                // Replace glyph in original array
                glyphs.set(j, clone);
            }
        }

        // Remove exceeding glyphs from original array
        int glyphCountdown = glyphCharIndex;
        for(int i = 0; i < runs.size; i++) {
            Array<Glyph> glyphs = runs.get(i).glyphs;
            if(glyphs.size < glyphCountdown) {
                glyphCountdown -= glyphs.size;
                continue;
            }

            for(int j = 0; j < glyphs.size; j++) {
                if(glyphCountdown < 0) {
                    glyphs.removeRange(j, glyphs.size - 1);
                    break;
                }
                glyphCountdown--;
            }
        }

        // Pass new layout with custom glyphs to BitmapFontCache
        cache.setText(layout, lastLayoutX, lastLayoutY);
    }

    /** Adds cached glyphs to the active BitmapFontCache as the char index progresses. */
    private void addMissingGlyphs() {
        // Add additional glyphs to layout array, if any
        int glyphLeft = glyphCharIndex - cachedGlyphCharIndex;
        if(glyphLeft < 1) return;

        // Get runs
        GlyphLayout layout = super.getGlyphLayout();
        Array<GlyphRun> runs = layout.runs;

        // Iterate through GlyphRuns to find the next glyph spot
        int glyphCount = 0;
        for(int runIndex = 0; runIndex < glyphRunCapacities.size; runIndex++) {
            int runCapacity = glyphRunCapacities.get(runIndex);
            if((glyphCount + runCapacity) < cachedGlyphCharIndex) {
                glyphCount += runCapacity;
                continue;
            }

            // Get run and increase glyphCount up to its current size
            Array<Glyph> glyphs = runs.get(runIndex).glyphs;
            glyphCount += glyphs.size;

            // Next glyphs go here
            while(glyphLeft > 0) {

                // Skip run if this one is full
                int runSize = glyphs.size;
                if(runCapacity == runSize) {
                    break;
                }

                // Put new glyph to this run
                cachedGlyphCharIndex++;
                TypingGlyph glyph = glyphCache.get(cachedGlyphCharIndex);
                glyphs.add(glyph);

                // Cache glyph's vertex index
                glyph.internalIndex = glyphCount;

                // Advance glyph count
                glyphCount++;
                glyphLeft--;
            }
        }
    }

    @Override
    public void draw(Batch batch, float parentAlpha) {
        super.validate();
        addMissingGlyphs();

        // Update cache with new glyphs
        BitmapFontCache bitmapFontCache = getBitmapFontCache();
        getBitmapFontCache().setText(getGlyphLayout(), lastLayoutX, lastLayoutY);

        // Tint glyphs
        for(TypingGlyph glyph : glyphCache) {
            if(glyph.internalIndex >= 0 && glyph.color != null) {
                bitmapFontCache.setColors(glyph.color, glyph.internalIndex, glyph.internalIndex + 1);
            }
        }

        super.draw(batch, parentAlpha);
    }

}