Skip to main content

Useful Swing Thing #1

Posted by ljnelson on December 17, 2007 at 12:35 PM PST

In Microsoft Outlook, along with probably dozens of other desktop applications, pressing ESC just about anywhere will cause the current window you're in to be cancelled. In the case of dialogs, it is as though the Cancel button were pressed; in the case of root windows, it is as though you tried to exit the application (and you usually get a warning dialog that asks you if you're sure).

For one of my applications, I wanted to get this very useful UI gesture installed once, in the right way, preferably without messing with the current look and feel, without requiring tons of custom code, and without subclassing. Here's a narrative of my journey, which also serves as a tutorial of sorts to the UIManager and UIDefaults classes.

For the impatient, here's the source code discussed in the article below: https://bricks.dev.java.net/source/browse/*checkout*/bricks/swing/jrootpanes/src/main/java/bricks/swing/jrootpanes/JRootPanes.java.

I started by looking into the UIDefaults class, since I hadn't really done much with the UIManager class and its related classes in general. To sum up, perhaps incorrectly, because I'm still not all that comfortable with it, you can think of the UIDefaults class as a hashtable containing all of the various UI attributes and properties that you can think of in the Java platform. (This is wrong because really those values come from the look and feel classes, and they're the ones who installed them, but for the moment forget about where these values came from and focus on how you get access to them.)

UIDefaults instances are maintained and shepherded around by the UIManager class, which has lots of useful static methods to work with those UIDefaults instances.

So, for example, if you want to see what the font is, by default, for the current look and feel's rendering of a JButton's text, you would say:
final Font font = (Font)UIManager.get("Button.font");
This is really a cover for:
final Font font = UIManager.getDefaults().get("Button.font");
...but is usually the idiom you'll see (and it's a whole lot more convenient).

(How do you know what keys and values there are? Well, obviously you could iterate over the results returned by UIManager.getDefaults().keys(), but failing that the only way to see what's in there is to examine the source of, for example, the BasicLookAndFeel class. That's probably a useful thing to look at anyhow.)

Picking up where we left off: if you're lucky and your JDK is normal you'll get back a non-null Font object that some LookAndFeel put there for you. That's why when you create a new JButton its font is...well, whatever that object is (usually it's the Dialog font). That is, you didn't have to set the font on the button yourself; the value was defaulted for you by the current look and feel.

The UIManager exposes three levels of these defaults: the user defaults, the look and feel defaults, and the system defaults. This happens transparently for callers: if you call UIManager.getDefaults() you get a UIDefaults object back that handles these three layers for you (in much the same way, for example, as java.util.Properties allows there to be a default Properties that is consulted in the case of a missed lookup).

If you never monkey with a UIDefaults object, then effectively you, the user, have never put in any user defaults, so anything you get out of that UIDefaults object is going to be either a look and feel default (i.e. the Metal or Ocean guys decided for you that teal or silver was an awesome color) or a system default (i.e. Microsoft's take on what color title bars should be). On the other hand, if you do put something into those defaults, then your value is the one that is returned, no matter what the current look and feel or system thinks the value should be.

Why is all this important? Because if you want to do something UI-ish by default—as in, "affecting every future instance of a UI class of some kind"—then you want to start here, because this is where those default values are managed.

Now, in this case I wasn't interested in colors or fonts or text or anything like that. I was interested in key bindings: InputMaps and ActionMaps. ActionMaps store Actions indexed by, conventionally, Strings; InputMaps store, conventionally, Strings indexed by KeyStrokes. Well, it turns out that InputMaps are available—like fonts and colors—in the UIDefaults as well, for pretty much every component. This would mean that if I could get my hands on, for example, the default ActionMap and the right kind of InputMap installed on JRootPane instances by default I might have a prayer of setting up a key binding for the ESCAPE key that would attempt to close the current window.

For the ActionMap—i.e. the place that stores an Action under a String key—it turns out that all JRootPane instances that have UI delegates that extend from BasicRootPaneUI (which includes pretty much all the JDK-supplied UIs as well as a good number of third-party ones) will look for an ActionMap stored in the UIManager under the key—surprise, surprise—"RootPane.actionMap". That means if you do this:
final ActionMap actionMap = (ActionMap)UIManager.get("RootPane.actionMap");
...you'll get back a non-null ActionMap containing all the Actions that JRootPanes will have if and when any is constructed.

InputMaps are a little more complicated. There are three of them for any given JComponent:

  • the one whose bindings are in effect when the component in question is focused
  • the one whose bindings are in effect when the component is a parent or ancestor of the focused component (and a suitable binding couldn't be found in that component's set of InputMaps
  • the one whose bindings are in effect when nothing else worked, and all we know is that the component in question is in a focused Window

At first glance, if you wanted an ESCAPE press to cause a Window to close, you might think to put the binding in the second location—the InputMap whose bindings are in effect when the JRootPane in question is an ancestor of a focused component. This would mean, for example, that if you had a text field with the cursor in it, and someone pressed ESCAPE, the following logic would occur:

  1. Does the text field have an ESCAPE binding in its WHEN_FOCUSED_COMPONENT InputMap? No.
  2. Does one of its ancestors have an ESCAPE binding in its WHEN_ANCESTOR_OF_FOCUSED_COMPONENT InputMap? Yes (this is what would happen in the case we're describing).

Well, we happen to be lucky in that this little sequence of code will give us the proper InputMap to work with:
final InputMap inputMap = UIManager.get("RootPane.ancestorInputMap");

(I'll have more to say in a moment about why this isn't quite right—hint: what if there isn't a component in the window that has the focus?—but it's pretty good for now.)

So with these tools, we now have a place to put an Action that will close the parent Window:

  public static final void installCloseAction() {
    assert EventQueue.isDispatchThread();
    ActionMap actionMap = (ActionMap)UIManager.get("RootPane.actionMap");
    if (actionMap == null) {
      actionMap = new ActionMap();
      UIManager.put("RootPane.actionMap", actionMap);
    }
    assert actionMap != null;
    if (actionMap.get("close") == null) {
      actionMap.put("close", new AbstractAction("Close") {
          @Override public final void actionPerformed(final ActionEvent event) {
            assert event != null;
            final JRootPane rootPane = (JRootPane)event.getSource();
            assert rootPane != null;
            final Container container = SwingUtilities.getAncestorOfClass(RootPaneContainer.class, rootPane);
            if (container instanceof JInternalFrame) {
              final JInternalFrame internalFrame = (JInternalFrame)container;
              if (internalFrame.isClosable()) {
                internalFrame.doDefaultCloseAction();
              }
            } else {
              final Window window;
              if (container instanceof Window) {
                window = (Window)container;
              } else {
                window = SwingUtilities.getWindowAncestor(rootPane);
              }
              if (window != null) {
                window.dispatchEvent(new WindowEvent(window, WindowEvent.WINDOW_CLOSING));
              }         
            }
          }
        });
    }
  }

Assuming this method is present in a class called JRootPanes, we can test this by using the following snippet of test code (this uses some JUnit assertions to help us out, and for brevity I've omitted all the cruft to get this running on the event dispatch thread):

JRootPanes.installCloseAction();
final JRootPane rootPane = new JRootPane();
final ActionMap actionMap = rootPane.getActionMap();
assertNotNull(actionMap);
final Action closeAction = actionMap.get("close");
assertNotNull(closeAction);

Neat! OK, so we have managed to affect the ActionMap prototype for all future JRootPane instances, regardless of look and feel. How are we going to trigger our "close" Action?

One way to attempt this might be to do something like the following:

  final KeyStroke escape = KeyStroke.getKeyStroke("ESCAPE");
  assert escape != null;
  final InputMap inputMap = (InputMap)UIManager.get("RootPane.ancestorInputMap");
  if (inputMap != null && inputMap.get(escape) == null) {
    inputMap.put(escape, "close");
  }

That would non-intrusively affect the bindings stored in all JRootPane instances' InputMaps that are consulted when the JRootPane in question is the ancestor of a focused component. But what about when the JRootPane is the ancestor of a bunch of components, none of which has the focus?

Here's where things get tricky and annoying. It turns out that there is no InputMap entry in the UIDefaults for JRootPanes for the WHEN_IN_FOCUSED_WINDOW case. This makes a certain amount of sense: InputMaps installed in such cases actually have to be associated with their component (they must be instances of ComponentInputMap, actually) and so at "defaults time" you don't have a component, so it follows that you can't have a ComponentInputMap, and so it follows that you can't install one as the WHEN_IN_FOCUSED_WINDOW InputMap. But wait a minute. Doesn't ESCAPE close the window when you pop up a JOptionPane? Why, yes, it sure does. Hmmm.

I took a spin further down in the mammoth BasicLookAndFeel class and discovered a weird little entry for JOptionPane defaults:

// lines 1192-3 of BasicLookAndFeel.java follow:
"OptionPane.windowBindings", new Object[] {
"ESCAPE", "close" },
Without (further) boring you to tears I discovered that there is an accepted idiom for building up the raw materials for WHEN_IN_FOCUSED_WINDOW InputMaps (for BasicLookAndFeel-descending look and feel implementations). Sure enough, if you trace the code back, you'll discover that the BasicOptionPaneUI UI delegate (the superclass of most JOptionPane UI delegates out there) grabs the key binding raw materials in here ("ESCAPE", "close") and converts them into a WHEN_IN_FOCUSED_WINDOW InputMap. And—oops, BasicRootPaneUI doesn't do this, which means that BasicRootPaneUI doesn't ever consult anything in the UIManager/UIDefaults machinery to make this happen or to provide an injection point.

(If BasicRootPaneUI were changed, it would do something like this in its implementation of createInputMap(int, JComponent):

  • Get the "RootPane.windowBindings" entry out of the UIDefaults.
  • Call LookAndFeel.makeComponentInputMap(rootPane, bindings);.

...but, well, it doesn't.)

Being the stubborn sort, I turned to the other giant cudgel thoughtfully given to us by the AWT guys—Toolkit.addAWTListener(). The only place to "inject" an InputMap of the proper variety—since without subclassing we can't really do it at JRootPane construction time—is when a JRootPane is made displayable. The code below monitors HierarchyEvents for those containing JRootPanes that have just been made displayable, and, when it finds such a JRootPane it tries to non-intrusively modify or install the WHEN_IN_FOCUSED_WINDOW InputMap:

  private static final KeyStroke escape = KeyStroke.getKeyStroke("ESCAPE");

  private static final AWTEventListener awtListener = new AWTEventListener() {
      @Override public final void eventDispatched(final AWTEvent event) {
        assert EventQueue.isDispatchThread();
        assert event != null;
        if (event instanceof HierarchyEvent && event.getID() == HierarchyEvent.HIERARCHY_CHANGED) {
          // This event represents a change in the containment hierarcy.
          // Now let's figure out what kind.
          final HierarchyEvent hevent = (HierarchyEvent)event;
          final Component changed = hevent.getChanged();
          if (changed instanceof JRootPane && ((hevent.getChangeFlags() & HierarchyEvent.DISPLAYABILITY_CHANGED) != 0) && changed.isDisplayable()) {
            // Aha!  A JRootPane has just been made displayable!
            final InputMap inputMap = ((JRootPane)changed).getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW);
            if (inputMap != null && inputMap.get(escape) == null) {
              inputMap.put(escape, "close");
            }
          }
        }
      }
    };

  public static final void makeEscapeCloseAllWindows() {
    assert EventQueue.isDispatchThread();
    final Toolkit toolkit = Toolkit.getDefaultToolkit();
    assert toolkit != null;
    toolkit.removeAWTEventListener(awtListener);
    installCloseAction(); // from before; see earlier in this article
    toolkit.addAWTEventListener(awtListener, AWTEvent.HIERARCHY_EVENT_MASK);
  }

Sure enough, if you invoke this code before you create any JDialogs or JFrames then silently all such windows will be closeable with the ESCAPE key.

Thanks for reading.

Related Topics >>

Comments

Laird, congrats for the article. Very well written and ...

Laird,

congrats for the article. Very well written and explained! It was a beauty ...

RGV