Multiple Document Interface with TornadoFX

December 22, 2018

Multiple Document Interface or MDI is a style of application that presents more than one application instance to the user. In MDI, each application instance will show the same type of data such as a Maven project or a MS-Word document. However, the specific data -- project or document -- will be different. Not all applications are suited for MDI. For instance, a client / server application might require one user interaction per workstation in order to function correctly.

This screenshot shows a demo application. In the foreground, a file LICENSE.txt is shown. The contents are displayed in a TextArea and the fully-qualified filename path appears in the title of the Stage. In the background, there is another window. In that window, a README.txt is shown and the document title shows the filename, README.txt.

Two Open MDI Documents

View

The UI consists of a Menubar, a TextArea, and a ProgressBar. Type Safe Builders define the Scene Graph. A Controller and a TaskStatus object area also injected. The following is the code listing of TextView which defines the arrangement of components. The ShutdownEvent definition will be discussed in a later section.

object ShutdownEvent : FXEvent(scope=DefaultScope) class TextView : View("") { val c : TextController by inject() val status : TaskStatus by inject() override val root = vbox { menubar { menu("File") { item("New Window") { action { val newScope = Scope() find<TextView>(newScope).openWindow(owner=null) } } item("Open") { action { val files = chooseFile( "Open File", filters=arrayOf(FileChooser.ExtensionFilter("Text Files", "*.txt") ) ) if( files.size > 0) { val f = files[0] c.loadFile(f.absolutePath) } } } item("Close") { action { currentStage!!.hide() } } separator() item("Exit") { action { fire(ShutdownEvent) } } } } vbox { textarea(c.data) { vgrow = Priority.ALWAYS } separator() progressbar { visibleProperty().bind( status.running ) progressProperty().bind( status.progress ) } paddingAll = 10.0 spacing = 4.0 vgrow = Priority.ALWAYS } prefWidth = 736.0 prefHeight = 414.0 } init { FX.eventbus.subscribe<ShutdownEvent>( DefaultScope, FXEventRegistration(ShutdownEvent::class, this, 1L) { root.scene.window!!.hide() } ) } override fun onBeforeShow() { currentStage!!.titleProperty().bind( c.filename ) } }

Both of the windows in the screenshot are instances of the View class. The View supports four actions. These are functions in the action handlers of the MenuItems.

New Window - Open an empty new instance of the View in a new Stage

Open - Open a document and load the contents into the current window

Close - Close the current window; if the window is the last one open, exit the app

Exit - Close each open window and exit the app

Scopes and the New Window Function

This demo starts as a typical TornadoFX program. An App subclass is associated with a View. In this case, SimpleText (the App) is built with the TextView class (the View) passed into its constructor. At this point, there is nothing MDI-specific in the application. If the user does not execute the New Window function, there's only a single set of objects to manage.

The New Window Function is invoked in the action handler for the MenuItem "New Window". The find() method provides runtime access to TornadoFX dependency injected components. It is the mechanism that will add specific TextController and TaskStatus objects to the TextView object. Usually, TextController and TaskStatus are singletons (only one of each exists app-wide). However, since I passed in a newly-created Scope, fresh instances of TextController and TaskStatus can be created. The returned TextView object is then shown with an openWindow() call. The owner parameter is null to keep the windows independent.

In the TornadoFX documentation, you'll find Scopes used to coordinate form action in non-MDI apps.

Open

The Open Function, invoked from the "Open" MenuItem, lets the user navigate to a text file. Selecting the text file will read the contents into an internal buffer that is bound to the TextArea control. See the following TextController listing. IO operations are wrapped up in a JavaFX Task -- absolutely essential -- the result of which is updated filename and data properties.

class TextController : Controller() { val filename = SimpleStringProperty() val data = SimpleStringProperty() fun loadFile(fn : String) { runAsync { updateProgress( 0.4, 1.0 ) val bufferedReader: BufferedReader = File(fn).bufferedReader() val text = bufferedReader.use { it.readText() } text } ui { filename.value = fn data.value = it } fail { alert(Alert.AlertType.ERROR, "File Can't Be Opened", content = "${it.javaClass.name} ${it.message}") } } }

In the Type Safe Builder for the TextArea, the contents are bound to the TextController.data property. Because the Stage doesn't exist at the program's initialization, the binding must be deferred. In the TextView class, I override onBeforeShow() to apply a binding to the Stage. Supposedly, this can also be done in onDock() , but I had a problem with this in my version of TornadoFX (1.17).

As mentioned earlier, there are different TextController object instances throughout in the app on New Window-invoked TextViews. This differs from a lot of the TornadoFX documentation and examples that inject a singleton Controller subclass.

Close and Exit

The Close Function, from the "Close" MenuItem, issues a hide() command to the currently-focused window. Because JavaFX defaults to close when there are no open windows, calling Close Function will exit the app. If there is more than one window being displayed because one or more New Window functions were called, only the current window will be closed.

Exit is an app-wide function. Executing this from the Exit MenuItem will signal each TextView object. This is performed through the EventBus. Back when the TextView class was listed, there was an object declaration for an FXEvent. Since we want all of the TextView objects to receive this FXEvent, it is declared on the DefaultScope.

In the TextView's init block, the TextView registers for the ShutdownEvent. The syntax is different than what you might find in the documentation because the DefaultScope must be used and not any other Scope that might have been created from a New Window call.

Startup

If no command-line arguments are specified, the app starts with an empty window. The program will take a filename argument which allows you to start the program and read in an initial file. For example,

$ java scopes.simpletext.SimpleTextKt C:/Users/Carl2/Documents/data1.txt

Displays the contents of data1.txt in the TextArea and sets the filename as the title of the Stage. (Assumes the CLASSPATH has been set up for Kotlin.) This is code that only needs to be accessed at startup, so the find() on the DefaultScope TextView is all that is needed. This is available in the App.start() function.

App and Main

class SimpleTextApp : App(TextView::class) { override fun start(stage: Stage) { super.start(stage) if( parameters.raw.size > 0 ) { find<TextView>().c.loadFile(parameters.raw[0]) } } } fun main(args: Array<String>) { launch<SimpleTextApp>(args) }

This article demonstrates a simple way to create an MDI app. With almost no extra code -- just a new Scope() call -- you can bring up additional instances of an application without having to track a lot of state. Where you need coordinate, there is the EventBus which can broadcast an FXEvent globally to interested parties.