HaxePunk/HaxePunk

View on GitHub
backend/flash/haxepunk/graphics/text/Text.hx

Summary

Maintainability
Test Coverage
package haxepunk.graphics.text;

import haxe.ds.StringMap;
import flash.geom.ColorTransform;
import flash.geom.Matrix;
import flash.text.TextField;
import flash.text.TextFormat;
import flash.text.TextFormatAlign;
import flash.Assets;
import flash.geom.Point;
import haxepunk.HXP;
import haxepunk.utils.Log;
import haxepunk.graphics.Image;
import haxepunk.graphics.atlas.Atlas;
import haxepunk.graphics.atlas.AtlasRegion;
import haxepunk.graphics.hardware.Texture;
import haxepunk.utils.Color;
import haxepunk.math.Vector2;

/**
 * Used for drawing text using embedded fonts.
 */
class Text extends Image
{
    static var tag_re = ~/<([^>]+)>([^(<\/)]+)<\/[^>]+>/g;

    /**
     * If the text field can automatically resize if its contents grow.
     */
    public var resizable:Bool = true;

    /**
     * Width of the text within the image.
     */
    @:isVar public var textWidth(get, null):Int = 0;
    inline function get_textWidth()
    {
        if (_needsUpdate) updateTextBuffer();
        return textWidth;
    }

    /**
     * Height of the text within the image.
     */
    @:isVar public var textHeight(get, null):Int = 0;
    inline function get_textHeight()
    {
        if (_needsUpdate) updateTextBuffer();
        return textHeight;
    }

    /**
     * If set, configuration for text border.
     */
    var border(default, set):Null<BorderOptions>;
    inline function set_border(options:Null<BorderOptions>):Null<BorderOptions>
    {
        this.border = options;
        if (options != null && options.alpha > 0 && _borderBuffer == null)
        {
            // create a second buffer for the border, to allow independently
            // changing its alpha/color without a full buffer update
            _borderBuffer = Texture.create(
                Std.int(_source.width + bufferMargin * 2),
                Std.int(_source.height + bufferMargin * 2),
                true
            );
            _borderBackBuffer = _borderBuffer.clone();
            _borderSource = _borderBuffer.clone();
            _borderRegion = Atlas.loadImageAsRegion(_borderSource);
        }
        _needsUpdate = true;
        return options;
    }

    /**
     * Text constructor.
     * @param text    Text to display.
     * @param x       X offset.
     * @param y       Y offset.
     * @param width   Image width (leave as 0 to size to the starting text string).
     * @param height  Image height (leave as 0 to size to the starting text string).
     * @param options An object containing optional parameters contained in TextOptions
     *                         font        Font family.
     *                         size        Font size.
     *                         align        Alignment (one of: TextFormatAlign.LEFT, TextFormatAlign.CENTER, TextFormatAlign.RIGHT, TextFormatAlign.JUSTIFY).
     *                         wordWrap    Automatic word wrapping.
     *                         resizable    If the text field can automatically resize if its contents grow.
     *                         color        Text color.
     *                         leading        Vertical space between lines.
     *                        richText    If the text field uses a rich text string
     */
    public function new(text:String = "", x:Float = 0, y:Float = 0, width:Int = 0, height:Int = 0, ?options:TextOptions)
    {
        if (options == null) options = {};

        // defaults
        if (!Reflect.hasField(options, "font")) options.font = HXP.defaultFont + ".ttf";
        if (!Reflect.hasField(options, "size")) options.size = 16;
        if (!Reflect.hasField(options, "align")) options.align = TextFormatAlign.LEFT;
        if (!Reflect.hasField(options, "color")) options.color = 0xFFFFFF;
        if (!Reflect.hasField(options, "resizable")) options.resizable = true;
        if (!Reflect.hasField(options, "wordWrap")) options.wordWrap = false;
        if (!Reflect.hasField(options, "leading")) options.leading = 0;
        if (!Reflect.hasField(options, "border")) options.border = null;

        _matrix = new Matrix();

        var fontObj = Assets.getFont(options.font);
        _format = new TextFormat(fontObj.fontName, options.size, 0xFFFFFF);
        _format.align = options.align;
        _format.leading = options.leading;

        _field = new TextField();
        _field.wordWrap = options.wordWrap;
        _field.defaultTextFormat = _format;
        if (options.filters != null)
        {
            _field.filters = options.filters;
        }
        _field.text = _text = text;
        _field.selectable = false;

        resizable = options.resizable;
        _styles = new StringMap<TextFormat>();

        _width = (width == 0 ? Std.int(_field.textWidth + 4) : width);
        _height = (height == 0 ? Std.int(_field.textHeight + 4) : height);

        var source = Texture.create(_width, _height, true);
        _source = source;
        _region = Atlas.loadImageAsRegion(_source);
        super();

        createBuffer();
        updateBuffer();

        this.x = x;
        this.y = y;
        this.border = options.border;
        this.size = options.size;
        this.color = options.color;

        _needsUpdate = true;
    }

    /**
     * Add a style for a subset of the text, for use with the richText property.
     *
     * Usage:
     *
     * ```
     * text.addStyle("red", {color: 0xFF0000});
     * text.addStyle("big", {size: text.size * 2, bold: true});
     * text.richText = "<big>Hello</big> <red>world</red>";
     * ```
     */
    public function addStyle(tagName:String, params:StyleType):Void
    {
        _styles.set(tagName, params);
        if (_richText != null) _needsUpdate = true;
    }

    function matchStyles()
    {
        _text = _richText;

        // strip the tags for the display field
        _field.text = tag_re.replace(_text, "$2");

        // set the text formats based on tag names
        _field.setTextFormat(_format);
        while (tag_re.match(_text))
        {
            var tagName = tag_re.matched(1);
            var text = tag_re.matched(2);
            var p = tag_re.matchedPos();
            _text = _text.substr(0, p.pos) + text + _text.substr(p.pos + p.len);
            // try to find a tag name
            if (_styles.exists(tagName))
            {
                _field.setTextFormat(_styles.get(tagName), p.pos, p.pos + text.length);
            }
#if debug
            else
            {
                Log.warning("Could not found text style '" + tagName + "'");
            }
#end
        }

#if debug
        if (_field.text != _text)
        {
            Log.warning("Text field and _text do not match!");
        }
#end
    }

    inline function getBitmapData(t:Texture):flash.display.BitmapData
    {
        return cast t;
    }

    /** @private Updates the drawing buffer. */
    public function updateTextBuffer()
    {
        _needsUpdate = false;

        if (_richText != null)
        {
            matchStyles();
        }
        else
        {
            _field.setTextFormat(_format);
        }

        _field.width = _width;
        _field.width = textWidth = Math.ceil(_field.textWidth + bufferMargin * 2);
        _field.height = textHeight = Math.ceil(_field.textHeight + bufferMargin * 2);

        if (resizable && (textWidth > _width || textHeight > _height))
        {
            if (_width < textWidth) _width = textWidth + Std.int(bufferMargin * 2);
            if (_height < textHeight) _height = textHeight + Std.int(bufferMargin * 2);
        }

        if (_width > _source.width || _height > _source.height)
        {
            resize(_width, _height, false);
        }
        else
        {
            _source.clearColor(0);
            if (border != null && border.alpha > 0) _borderSource.clearColor(0);
        }

        _field.width = _width;
        _field.height = _height;

        updateBuffer(true);
        if (border != null && border.alpha > 0)
        {
            getBitmapData(_borderSource).draw(getBitmapData(_borderBuffer));
        }
        getBitmapData(_source).draw(getBitmapData(_buffer));
    }

    function createBuffer()
    {
        if (_buffer != null) _buffer.dispose();
        _buffer = Texture.create(
            Std.int(_source.width + bufferMargin * 2),
            Std.int(_source.height + bufferMargin * 2),
            true
        );
        if (_borderBuffer != null)
        {
            _borderBuffer.dispose();
            _borderBuffer = _buffer.clone();
            _borderBackBuffer.dispose();
            _borderBackBuffer = _buffer.clone();
        }
    }

    /**
     * Updates the image buffer.
     */
    @:dox(hide)
    public function updateBuffer(clearBefore:Bool = false)
    {
        if (clearBefore)
        {
            _buffer.clearColor(0);
            if (border != null && border.alpha > 0)
            {
                _borderBuffer.clearColor(0);
                _borderBackBuffer.clearColor(0);
            }
        }
        if (_source == null) return;

        _matrix.setTo(1, 0, 0, 1, bufferMargin, bufferMargin);

        if (border != null)
        {
            drawBorder();
        }

        getBitmapData(_buffer).draw(_field, _matrix);
    }

    function drawBorder()
    {
        var bd = getBitmapData(_borderBuffer);
        var bbd = getBitmapData(_borderBackBuffer);
        bd.draw(_field, _matrix, _whiteTint);

        inline function drawBorder(ox, oy)
        {
            // two buffers are used because copyPixels from the same
            // BitmapData forces an expensive clone
            var _swap = bd;
            bd = bbd;
            bbd = _swap;

            var rect = getBitmapData(_buffer).rect;
            _offset.setTo(0, 0);
            bd.copyPixels(bbd, rect, _offset, true);
            _offset.setTo(ox, oy);
            bd.copyPixels(bbd, rect, _offset, true);
        }
        switch (border.style)
        {
            case FastShadow:
                drawBorder(border.size, border.size);
            case Shadow:
                for (_ in 0 ... border.size)
                {
                    drawBorder(1, 0);
                    drawBorder(0, 1);
                }
            case FastOutline:
                drawBorder(0, -border.size);
                drawBorder(-border.size, 0);
                drawBorder(border.size, 0);
                drawBorder(0, border.size);
            case Outline:
                for (_ in 0 ... border.size)
                {
                    drawBorder(0, -1);
                    drawBorder(-1, 0);
                    drawBorder(1, 0);
                    drawBorder(0, 1);
                }
        }
    }

    public function resize(width:Int, height:Int, redraw:Bool = true)
    {
        _width = width;
        _height = height;

        if (_width > _source.width || _height > _source.height)
        {
            _source = Texture.create(
                Std.int(Math.max(_width, _source.width)),
                Std.int(Math.max(_height, _source.height)),
                true
            );

            if (border != null && border.alpha > 0)
            {
                _borderSource = Texture.create(
                    Std.int(Math.max(_width, _source.width)),
                    Std.int(Math.max(_height, _source.height)),
                    true
                );
            }

            createBuffer();

            _region = Atlas.loadImageAsRegion(_source);

            if (_borderRegion != null)
            {
                _borderRegion = Atlas.loadImageAsRegion(_borderSource);
            }
        }
        if (redraw) updateBuffer();
    }

    /**
     * Text string.
     */
    public var text(get, set):String;
    inline function get_text():String return _text;
    function set_text(value:String):String
    {
        if (_text == value && _richText == null) return value;
        _field.text = _text = value;
        _needsUpdate = true;
        return value;
    }

    /**
     * Rich-text string with markup.
     *
     * Use `Text.addStyle` to control the appearance of marked-up text.
     */
    public var richText(get, set):String;
    inline function get_richText():String return (_richText == null ? _text : _richText);
    function set_richText(value:String):String
    {
        if (_richText == value) return value;
        var fromPlain = (_richText == null);
        _richText = value;
        if (_richText == "") _field.text = _text = "";
        if (fromPlain && _richText != null)
        {
            _format.color = 0xFFFFFF;
        }
        else
        {
            _needsUpdate = true;
        }
        return value;
    }

    /**
     * Gets the specified property, by also inspecting the underlying TextField and TextFormat objects.
     * Returns null if the property doesn't exist.
     */
    public function getTextProperty(name:String):Dynamic
    {
        var value = Reflect.getProperty(this, name);
        if (value == null) value = Reflect.getProperty(_field, name);
        if (value == null) value = Reflect.getProperty(_format, name);
        return value;
    }

    /**
     * Sets the specified property, by also inspecting the underlying TextField and TextFormat objects.
     * Returns true if the property has been found and set, false otherwise.
     */
    public function setTextProperty(name:String, value:Dynamic):Bool
    {
        var propertyFound:Bool = false;

        // chain of try-catch: ugly but Reflect.hasField doesn't work with non-anon structs
        try // on this Text
        {
            Reflect.setProperty(this, name, value);
            return true; // exit early to avoid calling update twice
        }
        catch (e:Dynamic)
        {
            try // on TextField
            {
                Reflect.setProperty(_field, name, value);
                propertyFound = true;
            }
            catch (e:Dynamic)
            {
                try // on TextFormat
                {
                    Reflect.setProperty(_format, name, value);
                    propertyFound = true;
                }
                catch (e:Dynamic) {}
            }
        }
        if (!propertyFound) return false;

        _needsUpdate = true;
        return true;
    }

    public function setBorder(style:BorderStyle = BorderStyle.FastOutline, size:Int = 1, color:Color = Color.Black, alpha:Float = 1)
    {
        border = {
            style: style,
            size: size,
            color: color,
            alpha: alpha,
        };
    }

    /**
     * Font family.
     */
    public var font(default, set):String;
    function set_font(value:String):String
    {
        if (font == value) return value;
        value = Assets.getFont(value).fontName;
        _format.font = font = value;
        _needsUpdate = true;
        return value;
    }

    /**
     * Font size.
     */
    public var size(default, set):Int;
    function set_size(value:Int):Int
    {
        if (size == value) return value;
        _format.size = size = value;
        _needsUpdate = true;
        return value;
    }

    /**
     * Font alignment.
     */
    public var align(default, set):TextAlignType;
    function set_align(value:TextAlignType):TextAlignType
    {
        if (align == value) return value;
        _format.align = value;
        _needsUpdate = true;
        return value;
    }

    /**
     * Leading (amount of vertical space between lines).
     */
    public var leading(default, set):Int;
    function set_leading(value:Int):Int
    {
        if (leading == value) return value;
        _format.leading = leading = value;
        _needsUpdate = true;
        return value;
    }

    /**
     * Automatic word wrapping.
     */
    public var wordWrap(default, set):Bool;
    function set_wordWrap(value:Bool):Bool
    {
        if (wordWrap == value) return value;
        _field.wordWrap = wordWrap = value;
        _needsUpdate = true;
        return value;
    }

    override function get_width():Int return Std.int(_width);
    override function get_height():Int return Std.int(_height);

    var bufferMargin(get, null):Float;
    inline function get_bufferMargin() return 2 + (border == null ? 0 : border.size);

    override public function render(point:Vector2, camera:Camera)
    {
        if (_needsUpdate) updateTextBuffer();

        if (border != null && border.alpha > 0)
        {
            // draw the border first
            var textRegion = _region,
                c = color,
                a = alpha;
            _region = _borderRegion;
            color = border.color;
            alpha = border.alpha;
            super.render(point, camera);
            _region = textRegion;
            color = c;
            alpha = a;
        }

        super.render(point, camera);
    }

    // Text information.
    var _width:Int;
    var _height:Int;
    var _matrix:Matrix;
    var _text:String;
    var _richText:String;
    var _field:TextField;
    var _format:TextFormat;
    var _styles:StringMap<TextFormat>;
    var _source:Texture;
    var _buffer:Texture;

    var _offset:Point = new Point();
    var _whiteTint:ColorTransform = new ColorTransform(1, 1, 1, 1, 0xff, 0xff, 0xff, 1);
    var _needsUpdate:Bool = true;

    var _borderTint:ColorTransform = new ColorTransform();
    var _borderBuffer:Texture;
    var _borderBackBuffer:Texture;
    var _borderRegion:AtlasRegion;
    var _borderSource:Texture;
}