As stated in a previous post I am in the process of building a GUI application and I would like to use Clojure for that. In this post I would like to write about my experiences building a small app for evaluation of the halgari/fn-fx Clojure library that aims at building GUIs with JavaFX in a declarative way, similar to what React does for the browser DOM.
Disclaimer: Why not Electron?
I also considered using Electron but I would prefer a solution that runs on the JVM so that I can make full use of that. However, if you can live with the JS runtime I would definitely recommend checking out Electron too. Probably I will have a follow-up post about Electron. :)
Description of the app to be built
I did implement a simple app concept with various technologies (e.g. fn-fx or Electron) to get some impressions how they differ. The app is quite simple but is supposed to cover some important basics like local file access, native dialogs, state updates and rich widgets:
- The app should have a dialog to import a local csv file
- The csv file should get imported (all numerical data)
- The file contents should get rendered in a table
- The data from the file gets rendered as a scatter plot, first column as x-values, each other column as y-values
If you prefer to get the complete code immediately, just check it out on GitHub (there is also an Electron version which is not yet finished though).
Project Setup
Leiningen
I am using Leiningen, here is what the project.clj
looks like:
(defproject fn-fx-ui "0.1.0-SNAPSHOT"
:description "FIXME: write description"
:url "http://example.com/FIXME"
:license {:name "Eclipse Public License"
:url "http://www.eclipse.org/legal/epl-v10.html"}
:dependencies [[org.clojure/clojure "1.8.0"]
[halgari/fn-fx "0.3.0-SNAPSHOT"]
[org.clojure/data.csv "0.1.3"]]
:main fn-fx-ui.javafx-init
:aot [fn-fx-ui.javafx-init]
:target-path "target/%s"
:profiles {:uberjar {:aot :all}})
Of course fn-fx
was added as a dependency and also org.clojure/data.csv
to handle the csv data. The only other important details are the declaration of the :main
namespace and explicitly enabling :aot
for that. This is a JavaFX initialisation namespace that we need for lein run
as well as running the application from an Uberjar.
JavaFX initialisation
Here is the whole fn-fx-ui.javafx-init
namespace:
(ns fn-fx-ui.javafx-init
(:require [fn-fx-ui.core :as core])
(:gen-class
:extends javafx.application.Application))
(defn -start [app stage]
(core/start {:root-stage? false}))
(defn -main [& args]
(javafx.application.Application/launch fn_fx_ui.javafx_init
(into-array String args)))
The :gen-class
statement in the namespace declaration is important! We need to generate a class for being able to use the -main
function as an entry point. But it is as important to extend the JavaFX Application
class: This is needed for JavaFX to start properly. If you don’t do that you might run into compilation issues and hanging applications on exit.
Extending Application
requires us to implement -main
as well as -start
. The main
function just launches the app via direct Java interop passing along the application class ( fn_fx_ui.javafx_init
is the name of the Java class generated due to our namespace declaration) and the main arguments.
Calling launch
ultimately invokes the -start
function. Here we simply call another start function from our core namespace core/start
. I am going to explain the argument {:root-stage? false}
in the next section.
Let us have a look at the core namespace now.
Application entry point and state handling
The application state is handled in a single atom on the namespace top-level:
(def initial-state
{:options {:csv {:first-row-headers false}}
:root-stage? true
:data [[]]})
(defonce data-state (atom initial-state))
initial-state
just exists to being able to reset the data-state
atom easily which can be very useful when working from the REPL.
Here is the start
function mentioned above, the entry point in our core application:
(defn start
([] (start {:root-stage? true}))
([{:keys [root-stage?]}]
(swap! data-state assoc :root-stage? root-stage?)
(let [handler-fn (fn [event]
(println event)
(try
(swap! data-state handle-event event)
(catch Throwable exception
(println exception))))
ui-state (agent (fx-dom/app (stage @data-state)
handler-fn))]
(add-watch
data-state :ui
(fn [_ _ _ _]
(send ui-state
(fn [old-ui]
(println "-- State Updated --")
(println @data-state)
(fx-dom/update-app old-ui
(stage @data-state)))))))))
The root-stage?
argument gets written to the application state atom. The handler-fn
receives events triggered from UI elements like clicking a button. In addition to printing some logs and handling exceptions it only updates the data-state
atom with whatever a function handle-event
returns when called with the UI event
. handle-event
is a multimethod handling the various events, we will see it later.
The next let binding is very important ui-state
is an agent holding the app created by the fn-fx
library via fx-dom/app
. The first argument is a “stage” returned by (stage @data-state)
. This is the rendered root element of the declaratively defined UI tree. We will see the UI tree in the next section. The app
function also takes the handler-fn
as an argument so it can invoke that with any UI events.
The last thing we need to do is to register a watcher on the data-state
atom. Whenever the state changes this watcher will be triggered and send an update function to the ui-state
agent. This will call fx-dom/update-app
with the old UI state and a new rendered “stage” based on the updated data-state
. This function will do a diff between the old state and the new state and update only the UI elements that need an update. You basically get the same update mechanism like you would in React and similar frameworks. Every UI update is driven by a change in the application state instead of fiddling with UI updates yourself.
Declaring the GUI
The stage
function we called above is being created by the following usage of the defui
macro provided by fn-fx
:
(defui Stage
(render [this {:keys [root-stage? data options] :as state}]
(controls/stage
:title "fn-fx-ui"
:on-close-request (force-exit root-stage?)
:shown true
:scene (controls/scene
:root (controls/border-pane
:top (controls/h-box
:padding (javafx.geometry.Insets. 15 12 15 12)
:spacing 10
:alignment (javafx.geometry.Pos/CENTER)
:children [(controls/button
:text "Import CSV"
:on-action {:event :import-csv
:fn-fx/include {:fn-fx/event #{:target}}})
(controls/check-box
:text "Import first row as headers"
:selected (get-in options [:csv :first-row-headers])
:on-action {:event :toggle-option
:path [:csv :first-row-headers]})
(controls/button
:text "Reset"
:on-action {:event :reset})])
:center (controls/v-box
:children [(table {:data data})
(plot {:data data})]))))))
This is a declarative description of our app root UI element as a simple Clojure map. Well, there is the initial definition of render
as an implementation of a protocol function (see IUserComponent
in fn-fx
) this is how you get the state
passed to be used in the UI tree.
The function calls like controls/stage
, controls/border-pane
and controls/check-box
are very thin wrappers around the JavaFX component APIs. The names arguments passed to those functions, like :text "Import CSV", are turned into corresponding setter methods of the Java API (e.g.
.setText(“Import CSV”)`. This is pretty cool, luckily the JavaFX API is very consistent across components and thus you can pretty much use any setter like that to define the component. I hope most of the settings make sense without having to explain them but when in doubt, please just check the corresponding doc for the JavaFX component and you will see it match easily.
I think noteworthy are three things here:
- the Stage component gets a handler for the
:on-close-request
(will trigger the Java setter [setOnCloseRequest] to force an application exit - UI actions are being defined via
:on-action
(maps to settersetOnAction
) - custom components
table
andplot
are being used in the very end
I am going to explain these three things in the next sections.
Forcing application exit
When closing the window of our stage, the one described in the declarative tree above, we still have the stage from the javafx-init
namespace if we used that (like we would with lein run
or from an Uberjar). This means the application process would not stop because we still have a running stage. But that remaining stage does not have an actual window which could be closed. So by setting root-stage?
to false
, I enforce closing the whole application when we close our main stage:
(defn force-exit [root-stage?]
(reify javafx.event.EventHandler
(handle [this event]
(when-not root-stage?
(println "Closing application")
(javafx.application.Platform/exit)))))
On a macOS machine you might not want to do that to mimic the OS behaviour of not closing applications automatically when closing the last window. You might want to tweak this to fit to your needs and maybe have an OS dependent solution.
Registering UI actions
Via :on-action
the UI actions are being set. The value can be a map of whatever you want to pass along to the registered event handler. Here is an example from the code above:
(controls/button
:text "Reset"
:on-action {:event :reset})
I am using the :event
key-value pair for dispatching in a multimethod handling all events. So this is a very simple example but you could have any additional data in the :on-action
map that you would like to use. fn-fx
has a cool mechanism to include contextual data in the map automatically. Here is an example that includes the target of an event (the component on which the event was triggered):
(controls/button
:text "Import CSV"
:on-action {:event :import-csv
:fn-fx/include {:fn-fx/event #{:target}}})
Here is how to use that contextual data from the event handler for the :import-csv
event:
(defmethod handle-event :import-csv
[{:keys [options] :as state} {:keys [fn-fx/includes]}]
(let [window (.getWindow (.getScene (:target (:fn-fx/event includes))))
dialog (doto (FileChooser.) (.setTitle "Import CSV"))
file (util/run-and-wait (.showOpenDialog dialog window))
data (with-open [reader (io/reader file)]
(doall (csv/read-csv reader)))]
(assoc state :file file :data
(if (get-in options [:csv :first-row-headers])
data
(cons (map #(str "x" (inc %)) (range (count (first data)))) data)))))
You can see that the event handler gets the state and the value of the :on-action
declaration (check back at start
: handle-event
is used as a function to update the data-state
via swap!
). The contextual data can easily be retrieved from the event. Above, destructuring is being used in the function signature. The first let
binding then uses the includes
map to get a JavaFX Window
object.
Open file dialog
A dialog is being built on the next binding. To show the dialog for opening a file, we use the fn-fx
function util/run-and-wait
to open the dialog on the JavaFX Application Thread (this is the UI thread!) and wait until it was closed. The return value is the selected file which then gets read, parsed and associated in the state map. The return value of handle-event
will be swapped as the new value into data-state
(see handler-fn
in start
function).
Custom components
Remember those calls to plot
and table
in the UI tree? Those are components defined in dedicated functions. Rendering a table requires an additional component for each column and also a factory to create table cell values. Everything we need for the table looks like this:
(defn cell-value-factory [f]
(reify javafx.util.Callback
(call [this entity]
(ReadOnlyObjectWrapper. (f (.getValue entity))))))
(defui TableColumn
(render [this {:keys [index name]}]
(controls/table-column
:text name
:cell-value-factory (cell-value-factory #(nth % index)))))
(defui Table
(render [this {:keys [data]}]
(controls/table-view
:columns (map-indexed
(fn [index name]
(table-column {:index index
:name name}))
(first data))
:items (rest data)
:placeholder (controls/label
:text "Import some data first"))))
I think this should not be too hard to understand. Just nested components with columns being built via map-indexed
. The stuff happening in cell-value-factory
is a very basic implementation to make JavaFX happily accept it and render the values.
JavaFX component constructor with mandatory arguments
Ok, does that sound a bit scary? At first I though I hit a wall with fn-fx
when I wanted to use the ScatterChart
component of JavaFX because it has some mandatory arguments for its constructor. With everything we saw so far, fn-fx
would first construct an object and then use setters to further specify attributes. This does not work with ScatterChart
but here is a solution, pay attention to the call of diff/component
:
(defui Plot
(render [this {:keys [data]}]
(diff/component [:javafx.scene.chart.ScatterChart
[]
[(javafx.scene.chart.NumberAxis.)
(javafx.scene.chart.NumberAxis.)]]
{:data (data->series data)})))
We call diff/component
with two arguments: A “type” of component which in our case is a vector with the first element being a key for the JavaFX class, the second argument is a vector with the names of named arguments (in our case []
) and the last element is a vector with the argument values (two JavaFX NumberAxis
here which are the mandatory arguments). The second argument are the specifications for the component the same way as we saw them before, so :data 123
would call the setter .setData(123)
.
So this way we can handle the situations where we have to invoke the constructor in a custom way instead of being able to just use whatever fn-fx
has already wrapped nicely.
To conclude the plotting component I want to show you the data->series
function:
(defn data->series [data]
(if (some? data)
(let [transpose #(apply mapv vector %)
transposed-data (transpose data)
xs (rest (first transposed-data))
build-series (fn [[name & ys]]
(diff/component :javafx.scene.chart.XYChart$Series
{:name name
:data (map
#(javafx.scene.chart.XYChart$Data.
(bigdec (first %))
(bigdec (second %)))
(transpose [xs ys]))}))]
(map build-series transposed-data))
[]))
I hope there are no big questions marks about that anymore now. We need to add multiple series to the plot each with a set of data points to be plotted.
Conclusions
Hopefully you got some insights about how to work with fn-fx
and if this is something you would like to do. I actually like it a lot after having had to figure out a couple of things on my own. Also I had to patch fn-fx
a bit as some things were not yet implemented or not working as I needed them to. So at the time writing this, there is an open PR for those changes. Kudos to Timothy for putting fn-fx
out there!!
The full code is available on GitHub. In my opinion this could be a very viable way of building cross-platform GUIs with Clojure, expecting to run into some issues seems fair considering how fresh the library is. So please be aware of that risk should you plan to rely on it.
If you have any questions about the topic, would like to sync or chat, please get in touch with me or just leave a comment. I am looking forward to continue my work with fn-fx
! Nevertheless I also plan to write about my experience with other approaches including Electron, so stay tuned.
Happy hacking!