Introduction
Hi Folks! As Eylon said, I'm a summer intern at Adobe, working with Eylon and others to develop Flex applications. I'm not a blogger, usually, but there's a first time for everything.
Although I have a lot of experience programming, particularly in Java, I had never written anything in Flex or ActionScript before. Consequently, I had to get my feet wet somehow.
One of the applications my team is working on happened to need "code hinting" for some of its inputs. The idea is that, much like Eclipse or FlexBuilder, text could be suggested to the user based on what they were typing. I was tasked, as a sort of warm-up, with writing this little widget.
After some trials and tribulations, I came up with widget I call CodeHintingTextInput. Since it's moderately useful and fairly flexible/extendable, and because I ran into a few interesting issues along the way, it was suggested I put this up in a blog. So here I am, blogging it.You can see it in action here. To try it out, try typing "John" into the Author field and hitting Ctrl-Space, or typing "program" into the Interface field.
For those who are interested, I'm going to first go over some of the features of my widget, then talk about a few of the issues I faced, then present the source code along with an example.
Features
Usefulness in different situations
As I thought about how to design my code hinter, I realized there are a number of different situations in which a code hinter is called for. I decided there were two important questions:
- Is the input a single item, or is it a list? In other words, is the whole content of the text field the criteria for completion, or is only a part? A simple search box, for instance, would probably have only one item, as the suggestions would be whole search queries. But the "to" field in an email application might need the ability to auto complete many names, in this case separated by commas.
- Do the contents of one suggestion depend on what the user has previously typed? The best example which answers "yes" to this question is Java or ActionScript class completion. If you type "m" in FlexBuilder, it doesn't offer you every class in the mx package, it offers you "mx." If you accept mx, then it offers you "controls," "components," etc. In other words, hints are only a level at a time. I decided that no code hinter would be complete without this feature.
I came up with a design which would allow someone to use this widget in any of the four situations that might result from answers to these two questions. I came up with two properties: listDelimeter and treeDelimeter. By default, these are both empty, resulting in functionality that would please someone who answered "no" to both questions above - in other words, a simple auto-complete feature, like ones offered by most web browsers.
Setting the listDelimeter to any number of characters makes typing those characters indicate the end of the current item. In other words, when matching suggestions, the widget will look back only as far as the last list delimiter.
Setting the treeDelimeter, on the other hand, tells the widget that, once it encounters a tree delimiter, to make suggestions which are the child suggestion of what was typed before the delimiter. That's probably not the clearest way to put it, but anyone who's familiar with any object-oriented IDE will recognize the applicability to class completion.
Some example applications:
- A field that asks you to enter your name. Single item, no trees involved. Don't set any delimeters.
- A field that asks for a list of participants in a meeting. List of items, but no trees involved. Set the list delimiter to ",".
- A field that asks for an mathematical expression based on some variables. This might not look like a list, but it is. It just happens there are several delimiters, each with a special meaning. Set the list delimiter to "+-*/^" and any other mathematical symbols you wish to implement.
- A field that asks for a comma separated list of ActionScript classes. Set the list delimiter to "," and the tree delimiter to ".".
Ease of Integration
I knew I would need to integrate this widget into our current code. Therefore,I wanted a component that could be integrated fairly transparently. For this reason, I chose to extend a TextInput widget. My goal was to be able to simply go through our code and change TextInput to CodeHintingTextInput, along with adding a few additional properties. Existing styles, properties, and event handlers would continue to work.
Configurability
I promised myself I would offer as much configuration as possible. I keep coming up with new properties that make my widget even more flexible, so far I've added a number of options, such as when the hints pop up, whether whitespace is ignored, and whether matches are case-sensitive
Integration with Data Structures
My initial version only accepted a list of strings as a source for suggestions. If you wanted to auto complete classes, you had to enumerate each class, with its fully qualified package name, into an array and then pass that array to the widget. Similarly, if you had a collection of objects which contained completion data (say, a Person object which had a Name field), you had to extract the relevant info into an array first.
I realized at this point that the class structure I would be auto-completing was modeled as a tree structure, rather than a list of classes, and that it was totally infeasible to convert it to a list. So instead, I decided to come up with a generalized solution which would allow my widget to offer suggestion from any kind of data structure. This resulted in a very flexible but slightly complicated solution.
Basically, I needed to be able to extract two pieces of data from any object I was given. First, the actual text to suggest. I called this the "name" of the object. Second, in the tree case, I needed to be able to extract the object's children. I decided to accomplish this by allowing the user to specify getName and getChildren callback functions. These functions take in an object and return a string or an array, respectively. The user can define these functions themselves, giving them complete flexibility.
To sweeten the deal, I provided two default callback functions, which mimic the original behavior. The default getName function, for all purposes, simply returns object.toString(). The default getChildren function performs some text parsing on a string containing tree delimiters, and effectively chops off the first portion of the string.
Interesting Issues
When to Popup?
One of the biggest issues with any suggestion tool is when to offer the suggestions. You don't really want the user to have to ask for suggestions, because the whole idea is to save the user work. On the other hand, you don't want it popping up all over the place, annoying the user! Achieving a balance is key.
I certainly don't claim to have a magic solution. I studied the behavior of Eclipse and FlexBuilder for inspiration. Basically, I came up with the following ideas:
- The user should always have the ability to summon the suggestion menu, and equally important, to dismiss it. I provided an Eclipse-like Ctrl-Space command to summon the menu, while Escape dismissed it.
- In some cases, the user always wants the menu to pop up. So there's a property that can be set so if there's a suggestion, the menu will always pop up.
- Assuming the user wants to summon the menu themselves, typing a list delimiter ends the current suggestion scope. If the user wants suggestions for the next scope, he or she should have to summon the menu again.
- Tree-like data especially lends itself to auto completion. So typing a tree delimiter should automatically summon the menu. This approach is also embraced by Eclipse and FlexBuilder.
As we continue to use this widget, we might and probably will change our minds about all this…
Defeating Low-level Text Input Behaviors
As it turns out, the up and down arrow keys, which I wanted to use to make a selection in the list, function like Home and End keys in a single line text field. So, when you tried to make your selection, the cursor would jump to one end or the other, which was extremely annoying and no good at all.
I investigated various ways to block this behavior. I experimented with various permutations of key up & key down events, preventDefault & stopPropogation, and capture & bubble phases. Unfortunately experiments showed this is inherent behavior in the Flash Player that can not be blocked.
So, by this point, you may be wondering what the answer is. Unfortunately, there really isn't a good solution, which is rather unfortunate. So, like any good programmer, I resorted to the ugliest tool in my toolbox: the hack. By using key listeners, I realized I could save the cursor's position a split second before it moved, then could restore it later. So, my solution did the following: save the cursor position, start a one millisecond timer, then have that timer fire off a method which set the cursor position back to the saved position. I can hear the groans already...
CursorMovedEvent - Or Lack Thereof
When I tried to make the popup follow the cursor, instead of appearing in a static location, I realized I needed to be notified when the cursor changed position. Unluckily, there's no event for that. To compensate, I resorted to trapping all key events which might move the cursor, such as right, left, home, end, etc. The problem was, as I mentioned earlier, these events are fired before the selection changes. So instead of being able to set the popup to the current location of the cursor, I had to calculate ahead of time where the cursor was going to be and move the popup there. Very fun.
Example
Here's a simple example of how to use this widget. It will generate an application that looks like this:
Basically, this application offers the user four fields as part of a "New Class" wizard, as might be used by an IDE. These four fields have differing levels of auto-completion, from none to a complete tree and list based completion model. Note that PathNode is a class I wrote which is not posted here. It represent a single node in a tree.
<?xml version="1.0" encoding="utf-8"?>
<mx:Application xmlns:mx="http://www.adobe.com/2006/mxml" layout="vertical" xmlns:local="*" horizontalAlign="center" creationComplete="init()">
<mx:Style source="mystyle.css" />
<mx:Script>
<![CDATA[
public function init():void
{
//create a list of possible authors
hintsource.addItem("John Doe");
hintsource.addItem("Jane Doe");
hintsource.addItem("Tom Nobody");
//create a list of possible classes
//as a list of strings
hintsource2.addItem("project.controls.SnazzyWidget");
hintsource2.addItem("project.controls.SimpleWidget");
hintsource2.addItem("project.base.BaseWidget");
hintsource2.addItem("project.base.ExtendedWidget");
hintsource2.addItem("project.unused.BadWidget");
hintsource2.addItem("common.CommonWidget");
//greate a list of possible interfaces
//as a tree
var project:PathNode = new PathNode("project");
var common:PathNode = new PathNode("common");
var controls:PathNode = new PathNode("controls");
var base:PathNode = new PathNode("base");
var unused:PathNode = new PathNode("unused");
var viewable:PathNode = new PathNode("Viewable");
var styleable:PathNode = new PathNode("Styleable");
var bindable:PathNode = new PathNode("Bindable");
var removable:PathNode = new PathNode("Removable");
var unusable:PathNode = new PathNode("Unusable");
var sharable:PathNode = new PathNode("Sharable");
project.addChild(controls);
project.addChild(base);
project.addChild(unused);
controls.addChild(styleable);
controls.addChild(bindable);
base.addChild(viewable);
base.addChild(removable);
unused.addChild(unusable);
common.addChild(sharable);
hintsource3.addItem(project);
hintsource3.addItem(common);
}
//Sample handlers who know how to handle a tree made up of PathNodes
public function getPathNodeName(obj:Object):String
{
var pn:PathNode = obj as PathNode;
return pn.getName();
}
public function getPathNodeChildren(obj:Object):Array
{
var pn:PathNode = obj as PathNode;
return pn.getChildren().toArray();
}
]]>
</mx:Script>
<mx:ArrayCollection id="hintsource" />
<mx:ArrayCollection id="hintsource2" />
<mx:ArrayCollection id="hintsource3" />
<mx:Panel title="New ActionScript Class" width="50%" height="75%" status="Please enter data" titleStyleName="heading2" verticalAlign="middle" horizontalAlign="center">
<!-- This input has no hints array, so behaves like a text input -->
<mx:Label text="Class Name" />
<local:CodeHintingTextInput minWidth="200" maxWidth="200"/>
<!-- This input has a data source but no delimiters -->
<mx:Label text="Author" />
<local:CodeHintingTextInput hints="{hintsource}" minWidth="200" maxWidth="200"/>
<!-- This input uses tree delimiters to specify a single class. Note it uses the -->
<!-- default handlers to parse a list of classes into a tree -->
<mx:Label text="Superclass" />
<local:CodeHintingTextInput hints="{hintsource2}" treeDelimitingString="." showWhenMatchesAvailable="true" minWidth="200" maxWidth="200"/>
<!-- This input uses tree delimiters and list delimeters for a list of classes! Also, -->
<!-- it uses custom handlers because its data source is a list of PathNodes, not strings -->
<mx:Label text="Implemented Interfaces" />
<local:CodeHintingTextInput id="treeguy" hints="{hintsource3}" treeDelimitingString="." listDelimitingString="," minWidth="200" maxWidth="200"
childrenFunction="getPathNodeChildren" nameFunction="getPathNodeName" showWhenMatchesAvailable="true"/>
</mx:Panel>
</mx:Application>
Let's examine the four fields created. The first has no hint data associated with it, and behaves just like a text input. The second is looking for a single value, a name. In order to get the popup, the user must summon it by pressing Ctrl-Space. The third asks for a single class name. This is a tree, but is specified by passing a list of fully qualified names as a list of strings. Note that the showWhenMatchesAvailable property has been enabled, making the suggestion list pop up without a request.
The fourth field is by far the most complicated. It is a list of classes, so it has both list and tree delimiters. Furthermore, it is passed a list of PathNodes, rather than a list of strings. A PathNode is a simple tree node, with a variable number of children and the appropriate accessor and mutator methods. However, by default there is no implementation to deal with this, so the user provides his own simple functions to allow the widget to understand the data structure.
And Finally, the Code
The code, associated documentation, and the example discussed can all be downloaded here.