[ Team LiB ] Previous Section Next Section

11.12 Describing GUIs with Properties

At its core, the task of specifying a graphical user interface is a descriptive one. This descriptive task does not map well onto a procedural and algorithm-based programming language such as Java. You end up writing lots of code that creates components, sets properties, and adds components to containers. Instead of simply describing the structure of the GUI you want, you must write the step-by-step code to build the GUI.

One way to avoid writing this tedious GUI construction code is to create a GUI-description language of some sort, then write code that can read that language and automatically create the described GUI. One common approach is to describe a GUI using an XML grammar. In this chapter, we'll rely on the simpler syntax of Java properties files as used by the ResourceBundle class. (See Chapter 8 for examples using java.util.ResourceBundle.)

A java.util.Properties object is a hashtable that maps string keys to string values. The Properties class can read and write a simple text file format in which each name:value line defines a single property. Furthermore, a Properties object can have a parent Properties object. When you look up the value of a property that does not exist in the child Properties object, the parent Properties object is searched (and this continues recursively). The ResourceBundle class provides an internationalization layer around properties files that allows properties to be customized for use in different locales. Internationalization is an important consideration for GUI-based applications, which makes the ResourceBundle class useful for describing GUI resources.

11.12.1 Handling Basic GUI Resources

Because properties files are text-based, one limitation to working with ResourceBundle objects that are based on properties files is that they support only String resources. The GUIResourceBundle class, presented in Example 11-22, is a subclass of ResourceBundle that adds additional methods for reading string resources and converting them to objects of the types commonly used in GUI programming, such as Color and Font.

The GUIResourceBundle code is straightforward. The ResourceParser interface provides an extension mechanism; we'll look at that next. Note that the MalformedResourceException class used in this example is not a standard Java class; it is a custom subclass of MissingResourceException that was developed for this example. Because it is a trivial subclass, its code is not shown here, but you'll find the code in the online example archive.

Example 11-22. GUIResourceBundle.java
package je3.gui;
import java.io.*;
import java.util.*;
import java.awt.*;

/**
 * This class extends ResourceBundle and adds methods to retrieve types of
 * resources commonly used in GUIs.  Additionally, it adds extensibility
 * by allowing ResourceParser objects to be registered to parse other
 * resource types.
 **/
public class GUIResourceBundle extends ResourceBundle {
    // The root object.  Required to parse certain resource types like Commands
    Object root;           

    // The resource bundle that actually contains the textual resources
    // This class is a wrapper around this bundle
    ResourceBundle bundle;

    /** Create a GUIResourceBundle wrapper around a specified bundle */
    public GUIResourceBundle(Object root, ResourceBundle bundle) {
        this.root = root;
        this.bundle = bundle;
    }

    /**
     * Load a named bundle and create a GUIResourceBundle around it.  This 
     * constructor takes advantage of the internationalization features of
     * the ResourceBundle.getBundle( ) method.
     **/
    public GUIResourceBundle(Object root, String bundleName)
        throws MissingResourceException
    {
        this.root = root;
        this.bundle = ResourceBundle.getBundle(bundleName);
    }

    /**
     * Create a PropertyResourceBundle from the specified stream and then
     * create a GUIResourceBundle wrapper for it
     **/
    public GUIResourceBundle(Object root, InputStream propertiesStream)
        throws IOException
    {
        this.root = root;
        this.bundle = new PropertyResourceBundle(propertiesStream);
    }

    /**
     * Create a PropertyResourceBundle from the specified properties file and
     * then create a GUIResourceBundle wrapper for it.
     **/
    public GUIResourceBundle(Object root, File propertiesFile) 
        throws IOException
    {
        this(root, new FileInputStream(propertiesFile));
    }

    /** This is one of the abstract methods of ResourceBundle */
    public Enumeration getKeys( ) { return bundle.getKeys( ); }

    /** This is the other abstract method of ResourceBundle */
    protected Object handleGetObject(String key)
        throws MissingResourceException
    {
        return bundle.getObject(key);  // simply defer to the wrapped bundle
    }
    
    /** This is a property accessor method for our root object */
    public Object getRoot( ) { return root; }

    /** 
     * This method is like the inherited getString( ) method, except that 
     * when the named resource is not found, it returns the specified default 
     * instead of throwing an exception 
     **/
    public String getString(String key, String defaultValue) {
        try { return bundle.getString(key); }
        catch(MissingResourceException e) { return defaultValue; }
    }

    /**
     * Look up the named resource and parse it as a list of strings separated
     * by spaces, tabs, or commas.
     **/
    public java.util.List getStringList(String key)
        throws MissingResourceException
    {
        String s = getString(key);
        StringTokenizer t = new StringTokenizer(s, ", \t", false);
        ArrayList list = new ArrayList( );
        while(t.hasMoreTokens( )) list.add(t.nextToken( ));
        return list;
    }

    /** Like above, but return a default instead of throwing an exception */
    public java.util.List getStringList(String key,
                                        java.util.List defaultValue) {
        try { return getStringList(key); }
        catch(MissingResourceException e) { return defaultValue; }
    }

    /** Look up the named resource and try to interpret it as a boolean. */
    public boolean getBoolean(String key) throws MissingResourceException {
        String s = bundle.getString(key);
        s = s.toLowerCase( );
        if (s.equals("true")) return true;
        else if (s.equals("false")) return false;
        else if (s.equals("yes")) return true;
        else if (s.equals("no")) return false;
        else if (s.equals("on")) return true;
        else if (s.equals("off")) return false;
        else {
            throw new MalformedResourceException("boolean", key);
        }
    }

    /** As above, but return the default instead of throwing an exception */
    public boolean getBoolean(String key, boolean defaultValue) {
        try { return getBoolean(key); }
        catch(MissingResourceException e) {
            if (e instanceof MalformedResourceException)
                System.err.println("WARNING: " + e.getMessage( ));
            return defaultValue;
        }
    }

    /** Like getBoolean( ), but for integers */
    public int getInt(String key) throws MissingResourceException {
        String s = bundle.getString(key);
        
        try {
            // Use decode( ) instead of parseInt( ) so we support octal
            // and hexadecimal numbers
            return Integer.decode(s).intValue( );
        } catch (NumberFormatException e) {
            throw new MalformedResourceException("int", key);
        }
    }

    /** As above, but with a default value */
    public int getInt(String key, int defaultValue) {
        try { return getInt(key); }
        catch(MissingResourceException e) {
            if (e instanceof MalformedResourceException)
                System.err.println("WARNING: " + e.getMessage( ));
            return defaultValue;
        }
    }

    /** Return a resource of type double */
    public double getDouble(String key) throws MissingResourceException {
        String s = bundle.getString(key);
        
        try {
            return Double.parseDouble(s);
        } catch (NumberFormatException e) {
            throw new MalformedResourceException("double", key);
        }
    }

    /** As above, but with a default value */
    public double getDouble(String key, double defaultValue) {
        try { return getDouble(key); }
        catch(MissingResourceException e) {
            if (e instanceof MalformedResourceException)
                System.err.println("WARNING: " + e.getMessage( ));
            return defaultValue;
        }
    }

    /** Look up the named resource and convert to a Font */
    public Font getFont(String key) throws MissingResourceException {
        // Font.decode( ) always returns a Font object, so we can't check
        // whether the resource value was well-formed or not.
        return Font.decode(bundle.getString(key));
    }

    /** As above, but with a default value */
    public Font getFont(String key, Font defaultValue) {
        try { return getFont(key); }
        catch (MissingResourceException e) { return defaultValue; }
    }

    /** Look up the named resource, and convert to a Color */
    public Color getColor(String key) throws MissingResourceException {
        try {
            return Color.decode(bundle.getString(key));
        }
        catch (NumberFormatException e) { 
            // It would be useful to try to parse color names here as well
            // as numeric color specifications
            throw new MalformedResourceException("Color", key);
        }
    }

    /** As above, but with a default value */
    public Color getColor(String key, Color defaultValue) {
        try { return getColor(key); }
        catch(MissingResourceException e) {
            if (e instanceof MalformedResourceException)
                System.err.println("WARNING: " + e.getMessage( ));
            return defaultValue;
        }
    }

    /** A hashtable for mapping resource types to resource parsers */
    static HashMap parsers = new HashMap( );

    /** An extension mechanism: register a parser for new resource types */
    public static void registerResourceParser(ResourceParser parser) {
        // Ask the ResourceParser what types it can parse
        Class[  ] supportedTypes = parser.getResourceTypes( );
        // Register it in the hashtable for each of those types
        for(int i = 0; i < supportedTypes.length; i++)
            parsers.put(supportedTypes[i], parser);
    }

    /** Look up a ResourceParser for the specified resource type */
    public static ResourceParser getResourceParser(Class type) {
        return (ResourceParser) parsers.get(type);
    }

    /**
     * Look for a ResourceParser for the named type, and if one is found, 
     * ask it to parse and return the named resource 
     **/
    public Object getResource(String key, Class type)
        throws MissingResourceException
    {
        // Get a parser for the specified type
        ResourceParser parser = (ResourceParser)parsers.get(type);
        if (parser == null) 
            throw new MissingResourceException(
                  "No ResourceParser registered for " +
                  type.getName( ) + " resources",
                  type.getName( ), key);
        
        try {  // Ask the parser to parse the resource
            return parser.parse(this, key, type);
        }
        catch(MissingResourceException e) {
            throw e;  // Rethrow MissingResourceException exceptions
        }
        catch(Exception e) {
            // If any other type of exception occurs, convert it to
            // a MalformedResourceException
            String msg = "Malformed " + type.getName( ) + " resource: " +
                key + ": " + e.getMessage( );
            throw new MalformedResourceException(msg, type.getName( ), key);
        }
    }

    /**
     * Like the 2-argument version of getResource, but return a default value
     * instead of throwing a MissingResourceException
     **/
    public Object getResource(String key, Class type, Object defaultValue) {
        try {  return getResource(key, type); }
        catch (MissingResourceException e) {
            if (e instanceof MalformedResourceException)
                System.err.println("WARNING: " + e.getMessage( ));
            return defaultValue;
        }
    }
}

11.12.2 An Extension Mechanism for Complex Resources

As we just saw, Example 11-22 uses the ResourceParser interface to provide an extension mechanism that allows it to handle more complex resource types. Example 11-23 is a listing of this simple interface. We'll see some interesting implementations of the interface in the sections that follow.

Example 11-23. ResourceParser.java
package je3.gui;

/**
 * This interface defines an extension mechanism that allows GUIResourceBundle
 * to parse arbitrary resource types
 **/
public interface ResourceParser {
    /**
     * Return an array of classes that specify what kind of resources
     * this parser can handle 
     **/
    public Class[  ] getResourceTypes( );

    /**
     * Read the property named by key from the specified bundle, convert
     * it to the specified type, and return it.  For complex resources,
     * the parser may need to read more than one property from the bundle; 
     * typically it may be a number of properties whose names begin with the 
     * specified key.
     **/
    public Object parse(GUIResourceBundle bundle, String key, Class type)
        throws Exception;
}

11.12.3 Parsing Commands and Actions

For our first ResourceParser implementation, we'll add the ability to parse Action objects. As we've seen, Action objects are commonly used in GUIs; an Action includes a number of attributes—such as a description, an icon, and a tooltip—that may need to be localized. Our ActionParser implementation is based on the CommandAction class shown in Example 11-16, which in turn relies on the reflection capabilities of the Command class shown in Example 9-2.

In order to implement the ActionParser class, you need to parse Command objects from a properties file. So let's start with the CommandParser class, shown in Example 11-24. This class is quite simple because it relies on the parsing capabilities of the Command class. The ActionParser listing follows in Example 11-25.

To help you understand how these parser classes work, consider the following properties, excerpted from the WebBrowserResources.properties file used by the WebBrowser class of Example 11-21:

action.home: home( );
action.home.label: Home
action.home.description: Go to home page
action.oreilly: displayPage("http://www.oreilly.com");
action.oreilly.label: O'Reilly
action.oreilly.description: O'Reilly & Associates home page

These properties describe two actions, one named by the key "action.home" and the other by "action.oreilly".

Example 11-24. CommandParser.java
package je3.gui;
import je3.reflect.Command;

/**
 * This class parses a Command object from a GUIResourceBundle.  It uses
 * the Command.parse( ) method to perform all the actual parsing work.
 **/
public class CommandParser implements ResourceParser {
    static final Class[  ] supportedTypes = new Class[  ] { Command.class };
    public Class[  ] getResourceTypes( ) { return supportedTypes;}

    public Object parse(GUIResourceBundle bundle, String key, Class type)
        throws java.util.MissingResourceException, java.io.IOException
    {
        String value = bundle.getString(key);  // look up the command text
        return Command.parse(bundle.getRoot( ), value);  // parse it!
    }
}
Example 11-25. ActionParser.java
package je3.gui;
import je3.reflect.*;
import java.awt.event.*;
import javax.swing.*;
import java.util.*;

/**
 * This class parses an Action object from a GUIResourceBundle.
 * The specified key is used to look up the Command string for the action.
 * The key is also used as a prefix for other resource names that specify
 * other attributes (such as the label and icon) associated with the Action.
 * An action named "zoomOut" might be specified like this:
 * 
 *      zoomOut: zoom(0.5);
 *      zoomOut.label: Zoom Out
 *      zoomOut.description: Zoom out by a factor of 2
 * 
 * Because Action objects are often reused by an application (for example,
 * in a toolbar and a menu system, this ResourceParser caches the Action
 * objects it returns.  By sharing Action objects, you can disable and enable
 * an action and that change will affect the entire GUI.
 **/
public class ActionParser implements ResourceParser {
    static final Class[  ] supportedTypes = new Class[  ] { Action.class };
    public Class[  ] getResourceTypes( ) { return supportedTypes; }

    HashMap bundleToCacheMap = new HashMap( );

    public Object parse(GUIResourceBundle bundle, String key, Class type)
        throws java.util.MissingResourceException
    {
        // Look up the Action cache associated with this bundle
        HashMap cache = (HashMap) bundleToCacheMap.get(bundle);
        if (cache == null) {  // If there isn't one, create one and save it
            cache = new HashMap( );
            bundleToCacheMap.put(bundle, cache);
        }
        // Now look up the Action associated with the key in the cache.
        Action action = (Action) cache.get(key);
        // If we found a cached action, return it.
        if (action != null) return action;

        // If there was no cached action, create one.  The command is
        // the only required resource.  It will throw an exception if
        // missing or malformed.
        Command command = (Command) bundle.getResource(key, Command.class);

        // The remaining calls all supply default values, so they will not
        // throw exceptions, even if ResourceParsers haven't been registered
        // for types like Icon and KeyStroke
        String label = bundle.getString(key + ".label", null);
        Icon icon = (Icon) bundle.getResource(key + ".icon", Icon.class, null);
        String tooltip = bundle.getString(key + ".description", null);
        KeyStroke accelerator = 
            (KeyStroke) bundle.getResource(key + ".accelerator", 
                                           KeyStroke.class, null);
        int mnemonic = bundle.getInt(key + ".mnemonic", KeyEvent.VK_UNDEFINED);
        boolean enabled = bundle.getBoolean(key + ".enabled", true);

        // Create a CommandAction object with these values
        action = new CommandAction(command, label, icon, tooltip,
                                   accelerator, mnemonic, enabled);

        // Save it in the cache, then return it
        cache.put(key, action);
        return action;
    }
}

11.12.4 Parsing Menus

We've seen that the GUIResourceBundle class makes it easy to read simple GUI resources, such as colors and fonts, from a properties file. We've also seen how to extend GUIResourceBundle to parse more complex resources, such as Action objects. Fonts, colors, and actions are resources that are used by the components that make up a GUI. With a small conceptual leap, however, we can start to think of GUI components themselves as resources to be used by the larger application.

Examples Example 11-26 and Example 11-27 show how this can work. These examples list the MenuBarParser and MenuParser classes, which read JMenuBar and JMenu objects, respectively, from a properties file. MenuBarParser relies on MenuParser to obtain the JMenu objects that populate the menubar, and MenuParser relies on the ActionParser class listed previously to obtain the Action objects that represent the individual menu items in each JMenu.

MenuParser and MenuBarParser read menu descriptions from properties files using a simple grammar illustrated by the following lines from the WebBrowserResource.properties file:

# The menubar contains two menus, named "menu.file" and "menu.go"
menubar: menu.file menu.go

# The "menu.file" menu has the label "File".  It contains five items
# specified as action objects, and these items are separated into two
# groups by a separator
menu.file: File: action.new action.open - action.close action.exit

# The "menu.go" menu has the label "Go", and contains four items
menu.go: Go: action.back action.forward action.reload action.home

These lines describe a menubar with the property name "menubar" and all its submenus. Note that I've omitted the properties that define the actions contained by the individual menu panes.

As you can see, the menubar grammar is quite simple: it is just a list of the property names of the menus contained by the menubar. For this reason, the MenuBarParser code in Example 11-26 is quite simple. The grammar that describes menus is somewhat more complicated, which is reflected in Example 11-27.

You may recall that the WebBrowser example also uses the GUIResourceBundle to read a JToolBar from the properties file. This is done using a ToolBarParser class. The code for that class is quite similar to the code for MenuBarParser and is not listed here. It is available in the online example archive, however.

Example 11-26. MenuBarParser.java
package je3.gui;
import javax.swing.*;
import java.util.*;

/**
 * Parse a JMenuBar from a ResourceBundle.  A menubar is represented
 * simply as a list of menu property names.  E.g.:
 *     menubar: menu.file menu.edit menu.view menu.help
 **/
public class MenuBarParser implements ResourceParser {
    static final Class[  ] supportedTypes = new Class[  ] { JMenuBar.class };
    public Class[  ] getResourceTypes( ) { return supportedTypes; }

    public Object parse(GUIResourceBundle bundle, String key, Class type)
        throws java.util.MissingResourceException
    {
        // Get the value of the key as a list of strings
        List menuList = bundle.getStringList(key);

        // Create a MenuBar
        JMenuBar menubar = new JMenuBar( );

        // Create a JMenu for each of the menu property names, 
        // and add it to the bar
        int nummenus = menuList.size( );
        for(int i = 0; i < nummenus; i++) {
            menubar.add((JMenu) bundle.getResource((String)menuList.get(i),
                                                   JMenu.class));
        }
        
        return menubar;
    }
}
Example 11-27. MenuParser.java
package je3.gui;
import je3.reflect.*;
import java.awt.event.*;
import javax.swing.*;
import java.util.StringTokenizer;

/**
 * This class parses a JMenu or JPopupMenu from textual descriptions found in
 * a GUIResourceBundle.  The grammar is straightforward: the menu label
 * followed by a colon and a list of menu items.  Menu items that begin with
 * a '>' character are submenus.  Menu items that begin with a '-' character
 * are separators.  All other items are action names.
 **/
public class MenuParser implements ResourceParser {
    static final Class[  ] supportedTypes = new Class[  ] {
        JMenu.class, JPopupMenu.class  // This class handles two resource types
    };

    public Class[  ] getResourceTypes( ) { return supportedTypes; }

    public Object parse(GUIResourceBundle bundle, String key, Class type)
        throws java.util.MissingResourceException
    {
        // Get the string value of the key
        String menudef = bundle.getString(key);

        // Break it up into words, ignoring whitespace, colons, and commas
        StringTokenizer st = new StringTokenizer(menudef, " \t:,");

        // The first word is the label of the menu
        String menuLabel = st.nextToken( );

        // Create either a JMenu or JPopupMenu
        JMenu menu = null;
        JPopupMenu popup = null;
        if (type == JMenu.class) menu = new JMenu(menuLabel);
        else popup = new JPopupMenu(menuLabel);

        // Then loop through the rest of the words, creating a JMenuItem
        // for each one.  Accumulate these items in a list
        while(st.hasMoreTokens( )) {
            String item = st.nextToken( );     // the next word
            char firstchar = item.charAt(0);  // determines type of menu item
            switch(firstchar) {
            case '-':   // words beginning with - add a separator to the menu
                if (menu != null) menu.addSeparator( );
                else popup.addSeparator( );
                break;
            case '>':   // words beginning with > are submenu names
                // strip off the > character, and recurse to parse the submenu
                item = item.substring(1);  
                // Parse a submenu and add it to the list of items
                JMenu submenu = (JMenu)parse(bundle, item, JMenu.class);
                if (menu != null) menu.add(submenu);
                else popup.add(submenu);
                break;
            case '!': // words beginning with ! are action names
                item = item.substring(1);   // strip off the ! character
                /* falls through */         // fall through to the next case
            default:  // By default all other words are taken as action names
                // Look up the named action and add it to the menu
                Action action = (Action)bundle.getResource(item, Action.class);
                if (menu != null) menu.add(action);
                else popup.add(action);
                break;
            }
        }

        // Finally, return the menu or the popup menu
        if (menu != null) return menu;
        else return popup;
    }
}
    [ Team LiB ] Previous Section Next Section