Skip to content
Permalink
Branch: master
Find file Copy path
Fetching contributors…
Cannot retrieve contributors at this time
250 lines (197 sloc) 8.82 KB

Meteor - Kotlin extensions for core Swing APIs

Maven Central radiance-meteor for build instructions of the latest stable release.

Working with listeners

In your Java app, this is how you would intercept the action to close the application window and prompt the user to save modified data:

this.addWindowListener(new WindowAdapter() {
    @Override
    public void windowClosing(WindowEvent e) {
        // do we need to save the modified scheme list?
        if (colorSchemeList.checkModifiedStateAndSaveIfNecessary()) {
            dispose();
        }
    }
});

Here is how the same code can look like after initial conversion to Kotlin:

this.addWindowListener(object : WindowAdapter() {
    override fun windowClosing(e: WindowEvent?) {
        // do we need to save the modified scheme list?
        if (colorSchemeList.checkModifiedStateAndSaveIfNecessary()) {
            dispose()
        }
    }
})

This still looks much like the original Java code. Let's take a look at the signature of Window.addDelayedWindowListener extension function:

inline fun Window.addDelayedWindowListener(
        crossinline onWindowActivated: (event: WindowEvent?) -> Unit = {},
        crossinline onWindowClosed: (event: WindowEvent?) -> Unit = {},
        crossinline onWindowClosing: (event: WindowEvent?) -> Unit = {},
        crossinline onWindowDeactivated: (event: WindowEvent?) -> Unit = {},
        crossinline onWindowDeiconified: (event: WindowEvent?) -> Unit = {},
        crossinline onWindowIconified: (event: WindowEvent?) -> Unit = {},
        crossinline onWindowOpened: (event: WindowEvent?) -> Unit = {}): WindowListener {

Note that there is no more usage of either WindowListener or WindowAdapter Java-side constructs in the input parameters. This means that we can move away from the rather awkward object: WindowAdapter() Kotlin-Java bridge. In addition, if you're only interested in a single type of WindowEvent, the resulting code looks Kotlin-first:

this.addDelayedWindowListener(onWindowClosing = {
    // do we need to save the modified scheme list?
    if (colorSchemeList.checkModifiedStateAndSaveIfNecessary()) {
        dispose()
    }
})

Note that since we are not inspecting the WindowEvent that is passed to onWindowClosing, it is simply omitted from the lambda that we pass to this extension function.

Tracking property changes

Component.firePropertyChange allows reporting bound property changes in a decoupled way. Here is how a custom Swing component might use it to track changes to a property:

public class JColorSchemeList extends JComponent {
  private boolean isModified;

  public boolean isModified() {
    return isModified;
  }

  public void setModified(boolean isModified) {
    if (this.isModified == isModified) {
      return;
    }

    boolean old = this.isModified;
    this.isModified = isModified;
    this.firePropertyChange("modified", old, isModified);
  }

Now, elsewhere in the app there's code that gets notified whenever this property is modified:

// track modification changes on the scheme list and any scheme in it
this.colorSchemeList.addPropertyChangeListener("modified", (PropertyChangeEvent evt) -> {
    boolean isModified = (Boolean) evt.getNewValue();
    SubstanceCortex.RootPaneScope.setContentsModified(getRootPane(), isModified);

    // update the main frame title
    updateMainWindowTitle(isModified);

    File currFile = colorSchemeList.getCurrentFile();
    saveButton.setEnabled(currFile != null);
});

Here we have boilerplate familiar to any Swing developer:

  • Getter and setter for each bound property.
  • Setter that returns early if the new value is the same as the current one.
  • Setter that calls firePropertyChange with the temporarily saved old and the new values.
  • Call to addPropertyChangeListener with the same exact string name for the bound property.
  • Explicit cast of the property value (new and / or old) inside that listener.

What can we do to remove most, if not all, of this boilerplate? Let's start with the property itself and use Kotlin's observables:

class JColorSchemeList : JComponent() {
  var isModified: Boolean by Delegates.observable(false) {
      prop, old, new -> this.firePropertyChange(prop.name, old, new)
  }

This is all we need to wire property change to integrate with the existing Swing mechanism for notifying observers on property change with firePropertyChange. What about the observer side?

// track modification changes on the scheme list and any scheme in it
this.colorSchemeList.addTypedDelayedPropertyChangeListener<Boolean>(
        this.colorSchemeList::isModified.name) { evt ->
    val isModified = evt.newValue ?: false

    // update the close / X button of the main frame
    this.rootPane.setContentsModified(isModified)

    // update the main frame title
    updateMainWindowTitle(isModified)

    // update the enabled state of the "save" button
    saveButton.isEnabled = (colorSchemeList.currentFile != null)
}

Here we use Meteor's typed property change listener to introduce type safety into querying the property value. For type completeness and null safety we use Kotlin's elvis operator to fall back on false.

In addition, note the use of ::isModified.name to make sure that both sides of the property change processing use the same underlying string name that will play well with codebase renaming and refactoring.

Working with actions

Adding an action to a JPopupMenu in Java can look like this:

JPopupMenu popupMenu = new JPopupMenu();
popupMenu.add(new AbstractAction("remove") {
    @Override
    public void actionPerformed(ActionEvent e) {
        zoomBubbles.remove(pressed.zoomBubble);
        repaint();
    }
});

With straightforward conversion to Kotlin the code becomes:

val popupMenu = JPopupMenu()
popupMenu.add(object : AbstractAction("remove") {
    override fun actionPerformed(e: ActionEvent) {
        zoomBubbles.remove(pressed.zoomBubble)
        repaint()
    }
})

And with Meteor it looks like this:

val popupMenu = JPopupMenu()
popupMenu.addAction("remove") {
    zoomBubbles.remove(pressed.zoomBubble)
    repaint()
}

Wiring key strokes to actions is a two-step process that requires matching string keys:

this.captionEditor = new JTextField(25);

InputMap im = this.captionEditor.getInputMap();
im.put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), "enter");
im.put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "escape");

ActionMap am = this.captionEditor.getActionMap();
am.put("enter", new AbstractAction() {
    public void actionPerformed(ActionEvent ae) {
        stopCaptionEdit(true);
    }
});

am.put("escape", new AbstractAction() {
    public void actionPerformed(ActionEvent ae) {
        stopCaptionEdit(false);
    }
});

And with Meteor it becomes a streamlined, compact expression:

this.captionEditor = JTextField(25)

this.captionEditor.wireActionToKeyStroke("enter",
        KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0)) {
    stopCaptionEdit(true)
}
this.captionEditor.wireActionToKeyStroke("escape",
        KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0)) {
    stopCaptionEdit(false)
}

Rendering with Graphics2D

Here is how a custom Icon might implement a simple rectangular color fill in its paintIcon:

@Override
public void paintIcon(Component c, Graphics g, int x, int y) {
    Graphics2D g2d = (Graphics2D) g.create();
    g2d.setColor(color);
    g2d.fillRect(x, y, w, h);
    float borderThickness = 1.0f / (float) NeonCortex.getScaleFactor();
    g2d.setColor(color.darker());
    g2d.setStroke(new BasicStroke(borderThickness, BasicStroke.CAP_ROUND,
            BasicStroke.JOIN_ROUND));
    g2d.draw(new Rectangle2D.Double(x, y, w - borderThickness, h - borderThickness));
    g2d.dispose();
}

And here is how the same code looks like with the Meteor-provided Graphics.render extension:

override fun paintIcon(c: Component, g: Graphics, x: Int, y: Int) {
    g.render {
        it.color = color
        it.fillRect(x, y, w, h)
        val borderThickness = 1.0f / NeonCortex.getScaleFactor().toFloat()
        it.color = color.darker()
        it.stroke = BasicStroke(borderThickness, BasicStroke.CAP_ROUND,
                BasicStroke.JOIN_ROUND)
        it.draw(Rectangle2D.Double(x.toDouble(), y.toDouble(),
                (w - borderThickness).toDouble(), (h - borderThickness).toDouble()))
    }
}

There is no more awkward dance caused by the backwards-compatible introduction of Graphics2D that is still there in the core Java even 20+ years after its introduction in 1998. And there is no more forgetting to dispose() on the Graphics2D object.

You can’t perform that action at this time.