I want to create a TopComponent class to use as an editor, not a singleton

Apache NetBeans Wiki Index

Note: These pages are being reviewed.

This entry is about creating non-text-editor (e.g. graphical) editors for files or other objects. If you want a text editor, NetBeans has a lot of built-in support for text editors and you will probably want to use DataEditorSupport.create() and its relatives (hint: New > File Type will get you basic text editor support which you can build on).

If you want to create some other kind of editor, you will probably want to start by creating a non-singleton TopComponent - a logical window, or tab, that can be opened in the editor area and can show your file or object in some way.

Our editor component will be fairly simple. It will have two constructors, one which takes a DataObject (the file) and one which has no arguments:

  public MyEditor() {
  }

  MyEditor(FooDataObject ob) throws IOException {
    init(ob);
  }

and it will have an initialization method. In our case, since this is a simple example, we will use a JTextArea. Our DataObject subclass will have a method setContent(String) which is passed the updated text if the user types into the text area. The DataObject will take care of marking the file modified and saving it when the user invokes the Save action. So we will just pass the text the user changed to the DataObject and update the tab name of the editor to show if the file is modified in-memory or not:

  void init(final FooDataObject file) throws IOException {
    associateLookup(file.getLookup());
    setDisplayName(file.getName());
    setLayout(new BorderLayout());
    add(new JLabel(getDisplayName()), BorderLayout.NORTH);
    //If you expect large files, load the file in a background thread
    //and set the field's text under its Document's lock
    final JTextField field = new JTextField(file.getPrimaryFile().asText());
    add(field, BorderLayout.CENTER);
    field.getDocument().addDocumentListener(new DocumentListener() {

      @Override
      public void insertUpdate(DocumentEvent e) {
        changedUpdate(e);
      }

      @Override
      public void removeUpdate(DocumentEvent e) {
        changedUpdate(e);
      }

      @Override
      public void changedUpdate(DocumentEvent e) {
        FooDataObject foo = getLookup().lookup(FooDataObject.class);
        foo.setContent(field.getText());
      }
    });
    file.addPropertyChangeListener(new PropertyChangeListener() {
      @Override
      public void propertyChange(PropertyChangeEvent evt) {
        if (DataObject.PROP_MODIFIED.equals(evt.getPropertyName())) {
          //fire a dummy event
          setDisplayName(Boolean.TRUE.equals(evt.getNewValue()) ? file.getName() + "*" : file.getName());
        }
      }
    });
  }

As of NetBeans 6.8, modified files are usually shown with a boldface tab name, so for consistency we should too:

 @Override
 public String getHtmlDisplayName() {
   DataObject dob = getLookup().lookup(DataObject.class);
   if (dob != null && dob.isModified()) {
     return &quot;<html>*&quot; + dob.getName();
   }
   return super.getHtmlDisplayName();
 }

The persistence code (described here) will save the file’s path on disk, and on restart, reinitialize the editor (if the file still exists).

The code to do this is actually quite simple - it can be boiled down to loading:

init (DataObject.find(FileUtil.toFileObject(FileUtil.normalizeFile(new File(properties.getProperty("path"))));

and saving

 properties.setProperty (FileUtil.toFile(dataObject.getPrimaryFile()).getAbsolutePath());

That is, all we are doing is saving a path on shutdown, and on restart looking that file up, transforming it into a NetBeans FileObject, and initializing with the DataObject for that. It just happens that we have to handle a few corner cases involving missing files and checked exceptions:

  • The file never really existed on disk (editing a template)

  • The file was deleted

  • The file cannot be read for some reason

So our persistence code looks like this:

 private static final String KEY_FILE_PATH = "path";
 void readProperties(java.util.Properties p) {
   String path = p.getProperty(KEY_FILE_PATH);
   try {
     File f = new File(path);
     if (f.exists()) {
       FileObject fileObject = FileUtil.toFileObject(FileUtil.normalizeFile(f));
       DataObject dob = DataObject.find(fileObject);
       //A DataObject always has itself in its Lookup, so do this to cast
       FooDataObject fooDob = dob.getLookup().lookup(FooDataObject.class);
       if (fooDob == null) {
         throw new IOException("Wrong file type");
       }
       init(fooDob);
       //Ensure Open does not create another editor by telling the DataObject about this editor
       fooDob.editorInitialized(this);
     } else {
       throw new IOException(path + " does not exist");
     }
   } catch (IOException ex) {
     //Could not load the file for some reason
     throw new IllegalStateException(ex);
   }
 }
 void writeProperties(java.util.Properties p) {
   FooDataObject dob = getLookup().lookup(FooDataObject.class);
   if (dob != null) {
     File file = FileUtil.toFile(dob.getPrimaryFile());
     if (file != null) { //could be a virtual template file not really on disk
       String path = file.getAbsolutePath();
       p.setProperty(KEY_FILE_PATH, path);
     }
   }
 }

Implementing A Very Simple DataObject For Our Very Simple Editor

The skeleton of our DataObject class is generated from the New > File Type template - this includes registering our DataObject subclass and associating it with a file extension. What we need to do is

  • Modify it so that Open on it will open our editor TopComponent, not a normal text editor

  • We will implement our own subclass of OpenCookie, which can create and open an instance of our editor, and remember and reuse that editor on subsequent invocations

  • Modify it so that we can pass the text the user typed to it, and it will mark itself modified and become savable (causing File > Save and File > Save All to become enabled)

  • We will implement the setContent(String) method to

  • Make a SaveCookie available, which is what the various built-in Save actions operate on

  • Call DataObject.setModified() — this guarantees that the user will be given a chance to save the file if they shut down the application before saving.

public class FooDataObject extends MultiDataObject {
  private String content;
  private final Saver saver = new Saver();
  public FooDataObject(FileObject pf, MultiFileLoader loader) throws DataObjectExistsException, IOException {
    super(pf, loader);
    CookieSet cookies = getCookieSet();
    cookies.add(new Opener());
  }

  @Override
  public Lookup getLookup() {
    return getCookieSet().getLookup();
  }

  synchronized void setContent(String text) {
    this.content = text;
    if (text != null) {
      setModified(true);
      getCookieSet().add(saver);
    } else {
      setModified(false);
      getCookieSet().remove(saver);
    }
  }

  void editorInitialized(MyEditor ed) {
    Opener op = getLookup().lookup(Opener.class);
    op.editor = ed;
  }

  private class Opener implements OpenCookie {
    private MyEditor editor;
    @Override
    public void open() {
      if (editor == null) {
        try {
          editor = new MyEditor(FooDataObject.this);
        } catch (IOException ex) {
          Exceptions.printStackTrace(ex);
        }
      }
      editor.open();
      editor.requestActive();
    }
  }

  private class Saver implements SaveCookie {
    @Override
    public void save() throws IOException {
      String txt;
      synchronized (FooDataObject.this) {
        //synchronize access to the content field
        txt = content;
        setContent(null);
      }
      FileObject fo = getPrimaryFile();
      OutputStream out = new BufferedOutputStream(fo.getOutputStream());
      PrintWriter writer = new PrintWriter(out);
      try {
        writer.print(txt);
      } finally {
        writer.close();
        out.close();
      }
    }
  }
}

Caveats For Production Use

A few things may be worth considering if you want to use code like this in a production environment:

  • File loading should usually happen on a background thread - put up some sort of progress bar inside the editor component, and replace its contents on the event thread after the load is completed - use RequestProcessor and EventQueue.invokeLater().

  • If it is expected that there will be a lot of FooDataObjects, Opener should instead keep a WeakReference to the editor component so that closed editors can be garbage collected. The following other changes would need to be made:

  • MyEditor should implement PropertyChangeListener directly

  • Use WeakListeners.propertyChange (this, file) rather than directly adding the editor as a listener to the DataObject

  • As of 6.9, the Openable interface is preferred to OpenCookie; a similar Savable interface is probably on the horizon to replace SaveCookie

  • The DataObject’s lookup could alternately be implemented using ProxyLookup and AbstractLookup and this will probably be the preferred way in the future