Alloy で AttributedString を使えるようにする

Titanium SDK 3.2.0.GA から追加された AttributedString を Alloy のビューとスタイルのみで使えるようにする、Alloy 自体の改造になります。

Titanium SDK 3.6 系から AttributedString が Android でもサポートされ、ネームスペースも Ti.UI.iOS.AttributedString から Ti.UI.AttributedString へ変更されるため、Alloy 自体の対応は 1.6 系からとなるようです（待てません。というか、諸事情で SDK と Alloy のバージョンを動かせない場合もありますし）。

そもそもコントローラに ```Ti.UI.iOS.createAttributedStinrg``` を直書きすれば良いのですが、Alloy を使っていて今更そんなこともしたくないですし。。。

というわけで、Alloy のコンパイラに手を入れて、ビューとスタイルのみで AttributedString を使えるようにしたいというわけです。実際にこれを適用すると、ビューとスタイルは以下のように記述できるようになります。

**index.xml**

```xml
<Alloy>
	<Window>
		<Label>
			<AttributedString class="as">
				Bacon ipsum dolor Appcelerator Titanium rocks! sit amet fatback leberkas salami sausage tongue strip steak.
			</AttributedString>
		</Label>
		<TextField>
			<AttributedString class="as">
				Bacon ipsum dolor Appcelerator Titanium rocks! sit amet fatback leberkas salami sausage tongue strip steak.
			</AttributedString>
			<AttributedHintText class="as">
				Bacon ipsum dolor Appcelerator Titanium rocks! sit amet fatback leberkas salami sausage tongue strip steak.
			</AttributedHintText>
		</TextField>
		<TextArea>
			<AttributedString class="as">
				Bacon ipsum dolor Appcelerator Titanium rocks! sit amet fatback leberkas salami sausage tongue strip steak.
			</AttributedString>
		</TextArea>
	</Window>
</Alloy>
```

**index.tss**

```css
"Window": {
	backgroundColor: '#fff',
	layout: 'vertical'
},
"Label": {
	top: 20,
	right: 10,
	left: 10,
	width: Ti.UI.FILL,
	height: Ti.UI.SIZE
},
"TextField": {
	top: 20,
	right: 10,
	left: 10,
	width: Ti.UI.FILL,
	height: 44
},
"TextArea": {
	top: 20,
	right: 10,
	left: 10,
	width: Ti.UI.FILL,
	height: 200
},
".as": {
	attributes: [
		{
			type: Ti.UI.ATTRIBUTE_UNDERLINES_STYLE,
			value: Ti.UI.ATTRIBUTE_UNDERLINE_STYLE_SINGLE,
			range: [0, 107]
		},
		{
			type: Ti.UI.ATTRIBUTE_BACKGROUND_COLOR,
			value: "red",
			range: [18, 12]
		},
		{
			type: Ti.UI.ATTRIBUTE_BACKGROUND_COLOR,
			value: "blue",
			range: [31, 2]
		},
		{
			type: Ti.UI.ATTRIBUTE_BACKGROUND_COLOR,
			value: "yellow",
			range: [40, 6]
		},
		{
			type: Ti.UI.ATTRIBUTE_FOREGROUND_COLOR,
			value: "orange",
			range: [0, 107]
		},
		{
			type: Ti.UI.ATTRIBUTE_FOREGROUND_COLOR,
			value: "black",
			range: [40, 6]
		}
	]
}
```

それではインストールされた Alloy のフォルダへ移動して、以下の改造を適用していきます。

**alloy/Alloy/common/constants.js**

186 行目にある ```AdView``` の下辺りに、以下の 1 行を挿入します。

```js
AttributedString: NS_TI_UI_IOS,
```

**alloy/Alloy/commands/compile/parsers/Ti.UI.iOS.AttributedString**

AttributedString 用に以下のコードを新規に設置します。

```js
var _ = require('../../../lib/alloy/underscore'),
	styler = require('../styler'),
	U = require('../../../utils'),
	CU = require('../compilerUtils'),
	tiapp = require('../../../tiapp');

var MIN_VERSION = '3.2.0';

exports.parse = function(node, state) {
	return require('./base').parse(node, state, parse);
};

function parse(node, state, args) {
	if (tiapp.version.lt(tiapp.getSdkVersion(), MIN_VERSION)) {
		U.die('Ti.UI.iOS.AttributedString (line ' + node.lineNumber + ') requires Titanium 3.2.0+');
	}

	// Get label text from node text, if present
	var nodeText = U.XML.getNodeText(node);
	if (nodeText) {
		if (U.isLocaleAlias(nodeText)) {
			state.extraStyle = {'text': styler.STYLE_EXPR_PREFIX + nodeText};
		} else {
			state.extraStyle = styler.createVariableStyle('text', "'" + U.trim(nodeText.replace(/'/g, "\\'")) + "'");
		}

		if (nodeText.match(/\{([^}]+)\}/) !== null) {
			state.extraStyle = nodeText;
		}
	}

	var nodeState = require('./default').parse(node, state);
	delete state.extraStyle;

	var code = nodeState.code;

	return {
		parent: {
			node: node,
			symbol: args.symbol
		},
		code: code
	};
}
```

**alloy/Alloy/commands/compile/parsers/Ti.UI.Label**

```Ti.UI.Label``` に ```AttributedString``` が読み込まれるように以下のコードをコピペします。

```js
var _ = require('../../../lib/alloy/underscore')._,
	styler = require('../styler'),
	CU = require('../compilerUtils'),
	U = require('../../../utils');

exports.parse = function(node, state) {
	return require('./base').parse(node, state, parse);
};

function parse(node, state, args) {
	var attributedStringsymbol,
		attributedStringObj = {},
		code = '';

	_.each(U.XML.getElementsFromNodes(node.childNodes), function(child){
		if (child.nodeName === 'AttributedString' && !child.hasAttribute('ns')) {
			child.setAttribute('ns', 'Ti.UI.iOS');
		}

		if (CU.validateNodeName(child, 'Ti.UI.iOS.AttributedString')) {
			code += CU.generateNodeExtended(child, state, {
				parent: {},
				post: function(node, state, args) {
					attributedStringsymbol = state.parent.symbol;
				}
			});

			node.removeChild(child);
		}
	});

	if (attributedStringsymbol) {
		attributedStringObj = styler.createVariableStyle('attributedString', attributedStringsymbol);
	}

	// Get label text from node text, if present
	var nodeText = U.XML.getNodeText(node),
		textObj = {};
	if (nodeText) {
		if (U.isLocaleAlias(nodeText)) {
			textObj = {'text': styler.STYLE_EXPR_PREFIX + nodeText};
		} else {
			textObj = styler.createVariableStyle('text', "'" + U.trim(nodeText.replace(/'/g, "\\'")) + "'");
		}

		if (nodeText.match(/\{([^}]+)\}/) !== null) {
			textObj["text"] = nodeText;
		}
	}

	state.extraStyle = _.extend(attributedStringObj, textObj);

	var nodeState = require('./default').parse(node, state);
	code += nodeState.code;

	// Generate runtime code using default
	return _.extend(nodeState, {
		code: code
	});
}
```

**alloy/Alloy/commands/compile/parsers/Ti.UI.TextField**

```Ti.UI.TextField``` に ```AttributedString``` と ```AttributedHintText``` が読み込まれるように以下のコードをコピペします。

```js
var _ = require('../../../lib/alloy/underscore')._,
	styler = require('../styler'),
	U = require('../../../utils'),
	CU = require('../compilerUtils'),
	CONST = require('../../../common/constants');

var KEYBOARD_TYPES = [
	'DEFAULT', 'ASCII', 'NUMBERS_PUNCTUATION', 'URL', 'EMAIL', 'DECIMAL_PAD', 'NAMEPHONE_PAD',
	'NUMBER_PAD', 'PHONE_PAD'
];
var RETURN_KEY_TYPES = [
	'DEFAULT', 'DONE', 'EMERGENCY_CALL', 'GO', 'GOOGLE', 'JOIN', 'NEXT', 'ROUTE',
	'SEARCH', 'SEND', 'YAHOO'
];
var AUTOCAPITALIZATION_TYPES = [
	'ALL', 'NONE', 'SENTENCES', 'WORDS'
];

exports.parse = function(node, state) {
	return require('./base').parse(node, state, parse);
};

function parse(node, state, args) {
	var code = '',
		postCode = '',
		extras = [],
		proxyProperties = {};

	// iterate through all children of the TextField
	_.each(U.XML.getElementsFromNodes(node.childNodes), function(child) {
		var fullname = CU.getNodeFullname(child),
			isProxyProperty = false,
			isControllerNode = false,
			isAttributedString = false,
			isAttributedHintText = false,
			hasUiNodes = false,
			controllerSymbol,
			parentSymbol;

		if (child.nodeName === 'AttributedString' && !child.hasAttribute('ns')) {
			child.setAttribute('ns', 'Ti.UI.iOS');
		}

		if (child.nodeName === 'AttributedHintText' && !child.hasAttribute('ns')) {
			child.nodeName = 'AttributedString';
			child.setAttribute('ns', 'Ti.UI.iOS');
			isAttributedHintText = true;
		}

		// validate the child element and determine if it's part of
		// the textfield or a proxy property assigment
		if (!CU.isNodeForCurrentPlatform(child)) {
			return;
		} else if (_.contains(CONST.CONTROLLER_NODES, fullname)) {
			isControllerNode = true;
		} else if (fullname.split('.')[0] === '_ProxyProperty') {
			isProxyProperty = true;
		} else if (CU.validateNodeName(child, 'Ti.UI.iOS.AttributedString')) {
			if (!isAttributedHintText) {
				isAttributedString = true;
			}
		}

		// generate the node
		code += CU.generateNodeExtended(child, state, {
			parent: {},
			post: function(node, state, args) {
				controllerSymbol = state.controller;
				parentSymbol = state.parent ? state.parent.symbol : state.item.symbol;
			}
		});

		// manually handle controller node proxy properties
		if (isControllerNode) {

			// set up any proxy properties at the top-level of the controller
			var inspect = CU.inspectRequireNode(child);
			_.each(_.uniq(inspect.names), function(name) {
				if (name.split('.')[0] === '_ProxyProperty') {
					var prop = U.proxyPropertyNameFromFullname(name);
					proxyProperties[prop] = controllerSymbol + '.getProxyPropertyEx("' + prop + '", {recurse:true})';
				} else {
					hasUiNodes = true;
				}
			});
		}

		// generate code for proxy property assignments
		if (isProxyProperty) {
			proxyProperties[U.proxyPropertyNameFromFullname(fullname)] = parentSymbol;

		// generate code for the attribtuedString
		} else if (isAttributedString) {
			proxyProperties.attributedString = parentSymbol;
			node.removeChild(child);

		// generate code for the attribtuedHintText
		} else if (isAttributedHintText) {
			proxyProperties.attributedHintText = parentSymbol;
			node.removeChild(child);

		// generate code for the child components
		} else if (hasUiNodes || !isControllerNode) {
			postCode += '<%= parentSymbol %>.add(' + parentSymbol + ');';
		}

	});

	// support shortcuts for keyboard type, return key type, and autocapitalization
	var keyboardType = node.getAttribute('keyboardType');
	if (_.contains(KEYBOARD_TYPES, keyboardType.toUpperCase())) {
		node.setAttribute('keyboardType', 'Ti.UI.KEYBOARD_' + keyboardType.toUpperCase());
	}
	var returnKey = node.getAttribute('returnKeyType');
	if (_.contains(RETURN_KEY_TYPES, returnKey.toUpperCase())) {
		node.setAttribute('returnKeyType', 'Ti.UI.RETURNKEY_' + returnKey.toUpperCase());
	}
	var autocapitalization = node.getAttribute('keyboardType');
	if (_.contains(AUTOCAPITALIZATION_TYPES, autocapitalization.toUpperCase())) {
		node.setAttribute('autocapitalization', 'Ti.UI.TEXT_AUTOCAPITALIZATION_' + autocapitalization.toUpperCase());
	}


	// add all creation time properties to the state
	if (node.hasAttribute('clearOnEdit')) {
		var attr = node.getAttribute('clearOnEdit');
		extras.push(['clearOnEdit', attr === 'true']);
	}
	_.each(proxyProperties, function(v, k) {
		extras.push([k, v]);
	});
	if (extras.length) { state.extraStyle = styler.createVariableStyle(extras); }

	// generate the code for the textfield itself
	var nodeState = require('./default').parse(node, state);
	code += nodeState.code;

	// add the rows to the section
	if (nodeState.parent && postCode) {
		code += _.template(postCode, {
			parentSymbol: nodeState.parent.symbol
		});
	}

	// Update the parsing state
	return _.extend(state, {
		parent: nodeState.parent,
		code: code
	});
}
```

**alloy/Alloy/commands/compile/parsers/Ti.UI.TextArea**

```Ti.UI.TextField``` に ```AttributedString``` が読み込まれるように以下のコードをコピペします。

```js
var _ = require('../../../lib/alloy/underscore')._,
	styler = require('../styler'),
	CU = require('../compilerUtils'),
	U = require('../../../utils'),
	CONST = require('../../../common/constants');

var KEYBOARD_TYPES = [
	'DEFAULT', 'ASCII', 'NUMBERS_PUNCTUATION', 'URL', 'EMAIL', 'DECIMAL_PAD', 'NAMEPHONE_PAD',
	'NUMBER_PAD', 'PHONE_PAD'
];
var RETURN_KEY_TYPES = [
	'DEFAULT', 'DONE', 'EMERGENCY_CALL', 'GO', 'GOOGLE', 'JOIN', 'NEXT', 'ROUTE',
	'SEARCH', 'SEND', 'YAHOO'
];
var AUTOCAPITALIZATION_TYPES = [
	'ALL', 'NONE', 'SENTENCES', 'WORDS'
];

exports.parse = function(node, state) {
	return require('./base').parse(node, state, parse);
};

function parse(node, state, args) {
	var code = '',
		postCode = '',
		extras = [],
		proxyProperties = {};

	// iterate through all children of the TextArea
	_.each(U.XML.getElementsFromNodes(node.childNodes), function(child) {
		var fullname = CU.getNodeFullname(child),
			isProxyProperty = false,
			isControllerNode = false,
			isAttributedString = false,
			hasUiNodes = false,
			controllerSymbol,
			parentSymbol;

		if (child.nodeName === 'AttributedString' && !child.hasAttribute('ns')) {
			child.setAttribute('ns', 'Ti.UI.iOS');
		}

		// validate the child element and determine if it's part of
		// the textarea or a proxy property assigment
		if (!CU.isNodeForCurrentPlatform(child)) {
			return;
		} else if (_.contains(CONST.CONTROLLER_NODES, fullname)) {
			isControllerNode = true;
		} else if (fullname.split('.')[0] === '_ProxyProperty') {
			isProxyProperty = true;
		} else if (CU.validateNodeName(child, 'Ti.UI.iOS.AttributedString')) {
			isAttributedString = true;
		}

		// generate the node
		code += CU.generateNodeExtended(child, state, {
			parent: {},
			post: function(node, state, args) {
				controllerSymbol = state.controller;
				parentSymbol = state.parent ? state.parent.symbol : null;
			}
		});

		// manually handle controller node proxy properties
		if (isControllerNode) {

			// set up any proxy properties at the top-level of the controller
			var inspect = CU.inspectRequireNode(child);
			_.each(_.uniq(inspect.names), function(name) {
				if (name.split('.')[0] === '_ProxyProperty') {
					var prop = U.proxyPropertyNameFromFullname(name);
					proxyProperties[prop] = controllerSymbol + '.getProxyPropertyEx("' + prop + '", {recurse:true})';
				} else {
					hasUiNodes = true;
				}
			});
		}

		// generate code for proxy property assignments
		if (isProxyProperty) {
			proxyProperties[U.proxyPropertyNameFromFullname(fullname)] = parentSymbol;

		// generate code for the attribtuedString
		} else if (isAttributedString) {
			proxyProperties.attributedString = parentSymbol;
			node.removeChild(child);

		// generate code for the child components
		} else if (hasUiNodes || !isControllerNode) {
			postCode += '<%= parentSymbol %>.add(' + parentSymbol + ');';
		}

	});

	// support shortcuts for keyboard type, return key type, and autocapitalization
	var keyboardType = node.getAttribute('keyboardType');
	if (_.contains(KEYBOARD_TYPES, keyboardType.toUpperCase())) {
		node.setAttribute('keyboardType', 'Ti.UI.KEYBOARD_' + keyboardType.toUpperCase());
	}
	var returnKey = node.getAttribute('returnKeyType');
	if (_.contains(RETURN_KEY_TYPES, returnKey.toUpperCase())) {
		node.setAttribute('returnKeyType', 'Ti.UI.RETURNKEY_' + returnKey.toUpperCase());
	}
	var autocapitalization = node.getAttribute('keyboardType');
	if (_.contains(AUTOCAPITALIZATION_TYPES, autocapitalization.toUpperCase())) {
		node.setAttribute('autocapitalization', 'Ti.UI.TEXT_AUTOCAPITALIZATION_' + autocapitalization.toUpperCase());
	}


	// add all creation time properties to the state
	if (node.hasAttribute('clearOnEdit')) {
		var attr = node.getAttribute('clearOnEdit');
		extras.push(['clearOnEdit', attr === 'true']);
	}
	_.each(proxyProperties, function(v, k) {
		extras.push([k, v]);
	});
	if (extras.length) { state.extraStyle = styler.createVariableStyle(extras); }

	// generate the code for the textarea itself
	var nodeState = require('./default').parse(node, state);
	code += nodeState.code;

	// add the rows to the section
	if (nodeState.parent && postCode) {
		code += _.template(postCode, {
			parentSymbol: nodeState.parent.symbol
		});
	}

	// Update the parsing state
	return _.extend(state, {
		parent: nodeState.parent,
		code: code
	});
}
```

以上になります。特に、```Ti.UI.TextField``` で hintText の色を変えたいとか、結構需要があったりしますよね。伝統芸能な、上にラベルをおいて change イベントを拾って。。。という実装もあるかと思いますが、attributedHintText を使ったほうが断然楽です。

**注意事項**

これは Alloy のコンパイラ自体に手を入れるため、自己責任でお願いします。ぼくの環境では問題なくコンパイルされますが、最悪 Alloy が壊れる可能性もあります。もちろん、Alloy 1.6 系で AttributedString がサポートされればこの改造は不要になります。