Skip to main content

Of Detachable Root Panes and Desktop Hopping

Posted by ljnelson on June 20, 2005 at 4:06 PM PDT

Long, long, long time no post. A job change and a two-year-old will do that to
you.

On today's menu: how to make a JRootPane subclass that can pop
itself in and out of JInternalFrames and JFrames. Let's dive right in.

Background

Different people have different ideas on how an application should work when
it can open many different documents of many different types at the same time,
and, most importantly, when its value derives from being able to see those
documents side-by-side (ruling out, for example, a href="http://java.sun.com/j2se/1.4.2/docs/api/javax/swing/JTabbedPane.html">JTabbedPane-based
interface). Some like a true, old-school href="http://en.wikipedia.org/wiki/Multiple_document_interface">MDI feel;
others like there to be multiple external windows, à la Microsoft Word.
My current project's customers are evenly divided on the subject.

To make everyone happy, I decided to start with a href="http://java.sun.com/j2se/1.4.2/docs/api/javax/swing/JDesktopPane.html">JDesktopPane-based
MDI core. Then I added the ability to detach the href="http://java.sun.com/j2se/1.4.2/docs/api/javax/swing/JInternalFrame.html">JInternalFrames
from the desktop pane and open them up as external windows—i.e. to make the window hop off the application desktop and onto the user desktop (and, potentially, back again). This comparatively
small insight has led to a surprisingly intuitive way of managing the
application's information. When you just want to look at one thing and have it
be your central focus, it is easier to have it be in an external window. On the
other hand, when you want to look at two things side by side, it seems easier to
grasp their relationship to each other and to their containing application if
they're presented inside an MDI.

The Code

The final code is available href="http://weblogs.java.net/blog/ljnelson/archive/code/ellington/DetachableRootPane.java">here
under the MIT
license
; my ramblings below detail how I designed it.

The Gory Details

Since both a JInternalFrame and a JFrame are href="http://java.sun.com/j2se/1.4.2/docs/api/javax/swing/RootPaneContainer.html">RootPaneContainers,
I focused on the href="http://java.sun.com/j2se/1.4.2/docs/api/javax/swing/JRootPane.html">JRootPane
class. If there were some way to pop instances of this class out of one kind of
window and into another kind—preferably via the usual href="http://java.sun.com/j2se/1.4.2/docs/api/javax/swing/ActionMap.html">ActionMap
machinery—I'd be in business.

If you look at the APIs for href="http://java.sun.com/j2se/1.4.2/docs/api/javax/swing/JFrame.html">JFrame
and href="http://java.sun.com/j2se/1.4.2/docs/api/javax/swing/JInternalFrame.html">JInternalFrame,
you can see that there is not a publicly accessible way to do this. In general,
this is a good thing—how many times (other than this one!) do you ever
even think about the root pane let alone want to remove it or set a new one?
While there is a public href="http://java.sun.com/j2se/1.4.2/docs/api/javax/swing/RootPaneContainer.html#getRootPane()">method
for retrieving the root pane on both the JFrame and
JInternalFrame classes, there is no such public setter method. So I
knew that overriding href="http://java.sun.com/j2se/1.4.2/docs/api/javax/swing/JRootPane.html">JRootPane
would be in order. I called my JRootPane subclass
DetachableRootPane.

Implementing the detach() method

Next, I focused on what would become this subclass' detach() method. What
would the state of this root pane be at the moment of detaching? What aspects
of that state would be worth preserving, and what aspects would be worth
throwing away?

This turned out to be a bit of a thorny problem. If a
JInternalFrame is maximized, and then detached, should the resulting
JFrame be maximized? Or should it be the same size on screen as the
maximized JInternalFrame it "came from"? If the
JInternalFrame were href="http://java.sun.com/j2se/1.4.2/docs/api/javax/swing/JInternalFrame.html#setResizable(boolean)">not
resizable, then should the resulting JFrame also not be
resizable? What about minimized JInternalFrames? Should they be
detachable at all?

Anyhow, at this stage in design I didn't focus too much on the answers to
these questions, but more on the fact that moving a DetachableRootPane
from a JInternalFrame to a JFrame and back again would require
certain elements of state to be carried through the detaching process. The
easiest thing to do, I figured, was to store this state using the normal href="http://java.sun.com/j2se/1.4.2/docs/api/javax/swing/JComponent.html#getClientProperty(java.lang.Object)">client
property mechanics.

Pass 1: Get the Basics Working

So, the detach() method began taking shape. Here's more or less
what it looked like at this stage in the design, where I was focusing on the
going-from-a-JInternalFrame-to-a-JFrame detaching
process. Note that the code below is intended to represent where I was in the
design process at the time; as such, it may not be correct or complete:

public void detach() {
  final Component parent = this.getParent();
  if (parent instanceof JInternalFrame) {
    // Leaving JInternalFrame; going into JFrame
    final JInternalFrame internalFrame = (JInternalFrame)parent;

    // Grab the JDesktopPane and stash it away because if we "go back" we need
    // to tell it to add() whatever JInternalFrame we "go back" to.  Note that if
    // abused, this could be the source of a memory leak.  There's probably
    // a way to make this more robust.  In short, don't abuse it.
    final JDesktopPane pane = internalFrame.getDesktopPane();
    if (pane != null) {
      pane.remove(internalFrame);
      this.putClientProperty("desktop", pane);
    }
   
    // Store some state about the JInternalFrame we're "leaving" so that if we
    // "come back" we can set up the new JInternalFrame just like the old one.
    // Resizability is tricky and isn't yet handled; more on this later.
    this.putClientProperty(INTERNAL_FRAME_CLOSABLE, Boolean.valueOf(internalFrame.isClosable()));
    this.putClientProperty(INTERNAL_FRAME_MAXIMIZABLE, Boolean.valueOf(internalFrame.isMaximizable()));
    this.putClientProperty(INTERNAL_FRAME_ICONIFIABLE, Boolean.valueOf(internalFrame.isIconifiable()));

    internalFrame.setVisible(false);

    // Create the new JFrame we're going to detach "into".
    final JFrame frame = new JFrame(internalFrame.getTitle()) {
      protected final void frameInit() {
        super.frameInit();
        this.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
        this.addWindowListener(new WindowAdapter() {
          public final void windowClosing(final WindowEvent event) {
            // Make it so that closing the external window causes us to "go
            // back into" a JInternalFrame.
            detach();
          }
        });
      }
      protected final JRootPane createRootPane() {
        return DetachableRootPane.this;
      }
    };

    // More on this later; we actually want to set bounds explicitly, but at
    // this stage in the design we'll punt.
    frame.pack();
    frame.setVisible(true);

  } else if (parent instanceof JFrame) {
    // Leaving JFrame; going into JInternalFrame
  }
}

At this stage, I had a DetachableRootPane that was capable of
popping out of a JInternalFrame into a JFrame if its
detach() method were ever called. The details to be worked out were:

  • Going the other way, i.e. detaching from a JFrame "into" a
    JInternalFrame
  • Setting the bounds properly so that the JFrame would
    appear to simply "drop into" the JDesktopPane as a
    JInternalFrame, and so that the JInternalFrame would appear to
    "pop off" the application desktop and onto the user's desktop
  • Miscellaneous state-related tracking behavior, like making sure that we
    monitor a JInternalFrame's resizability so that we know how to create a
    new one that looks just like it

Pass 2: Make Detaching Work Both Ways

The first thing I did was to make it so that the DetachableRootPane
could go the other way. Here is the section of code from the detach()
method that dealt with this issue. Again this is supposed to illustrate the
design process more than it is supposed to be compilable, correct code:

    } else if (parent instanceof JFrame) {
    // Leaving JFrame; going into JInternalFrame
    final JFrame frame = (JFrame)parent;

    // See if someone (usually us) has put a JDesktopPane into our client
    // property map.  If so, then we have the required parent component to add a
    // new JInternalFrame to.  If not, well, then we're up a creek, but handle
    // that gracefully too.
    final JDesktopPane pane = (JDesktopPane)this.getClientProperty("desktop");

    final String title = frame.getTitle();

    frame.dispose();

    if (pane != null) {
      // There is indeed a JDesktopPane to add a new JInternalFrame to, so do
      // it.

      final JInternalFrame internalFrame =
        new JInternalFrame(title,
                           true, // punt on resizability for now
                           ((Boolean)this.getClientProperty(INTERNAL_FRAME_CLOSABLE)).booleanValue(),
                           ((Boolean)this.getClientProperty(INTERNAL_FRAME_MAXIMIZABLE)).booleanValue(),
                           ((Boolean)this.getClientProperty(INTERNAL_FRAME_ICONIFIABLE)).booleanValue()) {
          protected final JRootPane createRootPane() {
            return DetachableRootPane.this;
          }
        };
      pane.add(internalFrame);

      // TODO: we'll worry about bounds in our next pass
      internalFrame.pack();

      internalFrame.setVisible(true);
    } // end of if block
  } // end of method

Brief Interlude: Pull In the ActionMap

At this stage in the process, I had a DetachableRootPane that could
quite comfortably spawn JFrames and JInternalFrames and hop in
and out of them at will, provided that the detach() method could
actually be invoked. I also had a test rig that was simply calling the
detach() method on a timer. This was proving to be really annoying now
that the basic functionality was there. So instead of diving into cleaning up
the details, I decided to work on the ActionMap- and
InputMap-related code. So, in the constructor for
DetachableRootPane I did this:

public DetachableRootPane() {
  super();
  final ActionMap actionMap = this.getActionMap();
  assert actionMap != null;
  actionMap.put("detach", new AbstractAction("Detach") {
      public final void actionPerformed(final ActionEvent event) {
        detach();
      }
    });
  final InputMap inputMap = this.getInputMap();
  assert inputMap != null;
  inputMap.put(KeyStroke.getKeyStroke("F2"), "detach");
}

Now when I pressed F2 on either a JInternalFrame or a
JFrame (provided, of course, its root pane was an instance of
DetachableRootPane), the frame would detach appropriately and move from
one desktop to the other.

Pass 3: Get the Bounds Working

Finally it was time to go back and worry about the bounds.

Going from a JInternalFrame in a JDesktopPane to the screen
was easy enough. All I had to do was call the href="http://java.sun.com/j2se/1.4.2/docs/api/java/awt/Component.html#getLocationOnScreen()">getLocationOnScreen()
method on the "outgoing" JInternalFrame and set the bounds of the new
JFrame accordingly. That part of the detach method now looked (in
part) like this:

      // internalFrame is the JInternalFrame that this DetachableRootPane is
      // "leaving".  Grab its *screen* location and set the bounds of the new
      // JFrame to those bounds.  The effect is of a seamless detaching from the
      // JDesktopPane.
      final Point point = internalFrame.getLocationOnScreen();
      assert point != null;
      bounds.x = point.x;
      bounds.y = point.y;

      // ...other code; see above in this weblog post...

      frame.setBounds(bounds);

Going the other way was more complicated and required me to wrap my head
around some of the more arcane-looking methods in href="http://java.sun.com/j2se/1.4.2/docs/api/javax/swing/SwingUtilities.html">SwingUtilities.
What I wanted to have happen was if someone closed the JFrame I wanted
its new JInternalFrame to appear in the JDesktopPane at
exactly the location "underneath" the JFrame that had just been
closed. After mucking around, I uncovered the href="http://java.sun.com/j2se/1.4.2/docs/api/javax/swing/SwingUtilities.html#convertPointFromScreen(java.awt.Point,%20java.awt.Component)">convertPointFromScreen()
method. The general approach, then, was to grab the screen location of the
"outgoing" JFrame and convert it to the JDesktopPane's
coordinate space. That part of the detach() method looked like
this:

      // ...other code...

      final Rectangle bounds = frame.getBounds();
      frame.dispose();
      if (pane != null) {
        final JInternalFrame internalFrame =
          new JInternalFrame(title,
                             true, // punt on resizability for now
                             ((Boolean)this.getClientProperty(INTERNAL_FRAME_CLOSABLE)).booleanValue(),
                             ((Boolean)this.getClientProperty(INTERNAL_FRAME_MAXIMIZABLE)).booleanValue(),
                             ((Boolean)this.getClientProperty(INTERNAL_FRAME_ICONIFIABLE)).booleanValue()) {
            protected final JRootPane createRootPane() {
              return DetachableRootPane.this;
            }
          };
        pane.add(internalFrame);
        final Point point = new Point(bounds.x, bounds.y);
        SwingUtilities.convertPointFromScreen(point, pane);
        bounds.x = point.x;
        bounds.y = point.y;
        internalFrame.setBounds(bounds);

        internalFrame.setVisible(true);

Pass 4: Cleaning Up and Putting It All Together

This looked pretty good. F2 on a JInternalFrame caused the
frame to pop off onto the user's desktop as a JFrame at exactly its
previous location on screen. F2 again caused that same JFrame
to sink down onto the JDesktopPane wherever it happened to be.

The last bit of work to do involved the resizability of the original
JInternalFrame. It turns out that when a JInternalFrame is
maximized, it effectively sets its resizable property to
false. This is a bug, because of course there are two semantic
properties at work here: the current resizability of the
JInternalFrame, and the structural resizability of the
JInternalFrame. It is true that when a frame of any kind is maximized,
typically you can't resize it, but that says nothing about whether it's
resizable once it has returned to its normal bounds. To put it in terms of this
project, I wanted to monitor the structural resizability of the
JInternalFrame and monitor it for any change; that way I could store
that property's value accurately in my DetachableRootPane for use
later—e.g. if the DetachableRootPane detaches into a
JFrame and back, when it comes back we want to make sure that the
JInternalFrame it comes back into is indistinguishable from the
JInternalFrame it left.

This, fortunately, sounds like a simple job for a href="http://java.sun.com/j2se/1.4.2/docs/api/java/beans/PropertyChangeListener.html">PropertyChangeListener.
The extra wrinkle is that we want to manage the PropertyChangeListener
in the DetachableRootPane, but install it on the
DetachableRootPane's parent. Because the parent has to be present for
this to work, we can't do the installation work in the
DetachableRootPane constructor; we have to do it in the href="http://java.sun.com/j2se/1.4.2/docs/api/java/awt/Component.html#addNotify()">addNotify()
method instead. This means we have to be careful to also remove it in the href="http://java.sun.com/j2se/1.4.2/docs/api/java/awt/Component.html#removeNotify()">removeNotify()
method:

  private boolean parentIsResizable;
  private PropertyChangeListener pcl;

  public void addNotify() {
    super.addNotify();
    final Container parent = this.getParent();
    boolean addPropertyChangeListener = true;
    if (parent instanceof JInternalFrame) {
      final JInternalFrame parentFrame = (JInternalFrame)parent;
      this.parentIsResizable = parentFrame.isResizable();
    } else if (parent instanceof JFrame) {
      final JFrame parentFrame = (JFrame)parent;
      this.parentIsResizable = parentFrame.isResizable();
    } else {
      addPropertyChangeListener = false;
    }
    if (addPropertyChangeListener) {
      this.pcl = new PropertyChangeListener() {
          public final void propertyChange(final PropertyChangeEvent event) {
            if (event != null && "resizable".equals(event.getPropertyName())) {
              final Boolean value = (Boolean)event.getNewValue();
              parentIsResizable = value != null && value.booleanValue();
            }
          }
        };
      parent.addPropertyChangeListener("resizable", this.pcl);
    }
  }

  public void removeNotify() {
    if (this.pcl != null) {
      final Container parent = this.getParent();
      assert parent != null;
      parent.removePropertyChangeListener("resizable", this.pcl);
    }
    super.removeNotify();
  }

Then I went back and amended the parts of my detach() method that
"punted" on resizability to take into account the real value of the property:

        if (parent instanceof JInternalFrame) {
          // ...other code...
          this.putClientProperty(INTERNAL_FRAME_RESIZABLE, new Boolean(this.parentIsResizable));
          // ...other code...
        } else if (parent instanceof JFrame) {
          // ...other code...
          final JInternalFrame internalFrame =
            new JInternalFrame(title,
                               ((Boolean)this.getClientProperty(INTERNAL_FRAME_RESIZABLE)).booleanValue(),
                               //...
        }

Finally, as my last bit of cleanup, I added a few static methods for
creating both JFrames and JInternalFrames with their root
panes set to an instance of DetachableRootPane. Here's one example:

  public static JInternalFrame createJInternalFrame(final String title, final boolean resizable, final boolean closable, final boolean maximizable, final boolean iconifiable) {
    return new JInternalFrame(title, resizable, closable, maximizable, iconifiable) {
        protected final JRootPane createRootPane() {
          return new DetachableRootPane();
        }
      };
  }

The Wrapup

This was a fun exercise, largely because it was well-bounded, solved a real
UI problem, and is really quite reusable in the large. My respect for the Swing
architects who carefully allowed me to get this much rope has just gone up
another notch.

Thanks for reading, and I'll see you at JavaOne.

Related Topics >>