The Source for Java Technology Collaboration
User: Password:



Laird Nelson's Blog

June 2005 Archives


Of Detachable Root Panes and Desktop Hopping

Posted by ljnelson on June 20, 2005 at 04:06 PM | Permalink | Comments (5)

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 JTabbedPane-based interface). Some like a true, old-school 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 JDesktopPane-based MDI core. Then I added the ability to detach the 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 here under the MIT license; my ramblings below detail how I designed it.

The Gory Details

Since both a JInternalFrame and a JFrame are RootPaneContainers, I focused on the 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 ActionMap machinery—I'd be in business.

If you look at the APIs for JFrame and 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 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 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 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 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 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 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 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 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 addNotify() method instead. This means we have to be careful to also remove it in the 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.





Powered by
Movable Type 3.01D
 Feed java.net RSS Feeds