Clojure/FX My Life: 0.1.3: Navigations

·

14 min read

The original Java "FX My Life V" was mostly thrashing around with the node controller and trying to grasp with some erroneous ideas about how generically one can treat FX nodes. In particular, you get a nice little clean graph like, oh, this one from the Oracle site:

image.png

And the reality is much sloppier. Much. But having hashed that out, we don't really need to "re-do" that in Clojure. Part VI dealt with navigating between screens, which involves adding more event-handling code to our controllers, which we should do. (Keeping in mind that we're likely to end up completely abandon the whole controller approach.)

Let's take the "Back" button in the frame's fxml and add an action:

 <Button text="_Back" onAction="#navigateBackwards" />

You could do this from the SceneBuilder as well, I suppose, but I just typed it in. And of course it's read because there is no such method. So we add the method to Controller:

:methods [[navigateBackwards [javafx.event.ActionEvent] void]])

Just like we did with TileController, though, we have to get the right parameters. Now the #navigateBackwards text turns green. Which still kind of amazes me. I don't know if it's Cursive or IJ or a combination but the editor is looking into the generated class (which we haven't generated yet) and figuring out what its methods are.

I just realized I didn't mention what I'm using to develop. IntelliJ, as with Java, but also Cursive, which is the premiere Clojure plug-in for IJ.

I'm going to clean up Controller a little bit:

(ns fxgamesc.Controller
  (:require [fxgamesc.transitions :refer [get-node add-node]]))

(gen-class
  :name fxgamesc.Controller
  :init init
  :constructors {[] []}
  :methods [[navigateBackwards [javafx.event.ActionEvent] void]])

(defn central []
  (.getCenter (get-node :frame)))

(defn -init []
  (add-node :tiles-panel "tilespanel.fxml")
  (add-node :ttt-grid "tttgrid.fxml"))

(defn -navigateBackwards [this e]
  (println "BACK!"))

I took the :main key out of gen-class. At first I thought it had to be there and had to be true, but I think it's only for running the class from the command line.

Next I took out the :prefix. This allows you to put a bunch of classes into one file, which is doubtless handy but we're synching, more-or-less with the Java code where...you can't. Since the prefix is used for the published method names, I renamed the -init function accordingly, and added -navigateBackwards.

This works. Go on, test it, you'll see "BACK!" every time you click the button.

The actual code for the button is kind of tacky:

    if (nodeController.getActive() != nodeController.node("tiles-panel"))
            NodeController.me.activate("tiles-panel", central);

Less "Back" and more "Home", but I think that's primarily because we haven't had cause to do anything more sophsiticated—yet. In Clojure, let's see, that would be:

(defn -navigateBackwards [this e]
  (if (= active (get-node :tiles-panel)
       (activate :tiles-panel))))

Exceptin' we don't have an active. And while state is something we add willy-nilly to objects in Java, in Clojure, it's a different story. We almost certainly would prefer to be able to look at the current state of the scene graph to find out what the currently active node is, and we may get to that point. But what if, for now, we did this:

(defn -navigateBackwards [this e]
  (if (not= (get-node :active) (get-node :tiles-panel))
         (activate :tiles-panel)))

So, in our node hash-map we'll have a key for the :active node, which we can check against :tiles-panel and, voila! When we use activate, then, we'll just set that key:

(defn activate
  ([new-node owner]
   (on-fx-thread
     (let [n (get-node new-node)
. . .
       (add-raw :active n))))
. . .

That's super slick. Now, if we're doing something with a lot of nodes where there might be a name collision, we might want to look at using more descriptive and namespaced keywords (like ":main/active").

We've mimicked the Java code down to the original jenky animations. That's also cool. That takes care of part VI.

Adventures In Grid Panes

Part VII of the Java series began our struggle with the GridPane, a layout control we wanted to use for board games, to which it is not especially suited. We'll start by adding onDragDetected methods to the tic-tac-toe grid in the FXML:

            <ImageView id="X" fx:id="X" fitHeight="79.0" fitWidth="105.0" onDragDetected="#handleOnDragDetected" pickOnBounds="true" preserveRatio="true">
. . .
            <ImageView id="O" fx:id="O" fitHeight="79.0" fitWidth="105.0" onDragDetected="#handleOnDragDetected" pickOnBounds="true" preserveRatio="true" GridPane.rowIndex="1">

Once again, I'm doing this directly in the text file, though there's no reason using SceneBuilder wouldn't work. Now we add the method to the controller, which I'll clean up along the same lines as Controller:

(ns fxgamesc.ttt.TttController)

(gen-class
  :name fxgamesc.ttt.TttController
  :init init
  :constructors {[] []}
  :methods [[onDragDetected [javafx.scene.input.MouseEvent] void]])

(defn -init[]
  (println "Tic-tac-toe!"))

(defn -onDragDetected[this e])

Our drag code in Java was:

public void handleOnDragDetected(MouseEvent event) {
    System.out.println("Drag. ");

    Dragboard db = X.startDragAndDrop(TransferMode.ANY);

    ClipboardContent content = new ClipboardContent();
    content.putString("X");
    db.setContent(content);
}

Before we convert this, let's see what we're getting in -onDragDetected:

(defn -onDragDetected [this e]
  (println "Drag: " e))

Yields:

Drag:  #object[javafx.scene.input.MouseEvent 0x7efda930 MouseEvent [source = ImageView[id=X, styleClass=image-view], target = ImageView[id=X, styleClass=image-view], eventType = DRAG_DETECTED, consumed = false, x = 35.0, y = 42.0, z = 0.0, button = PRIMARY, primaryButtonDown, pickResult = PickResult [node = ImageView[id=X, styleClass=image-view], point = Point3D [x = 35.0, y = 42.0, z = 0.0], distance = 1119.6152422706632]]]

When we drag from "X". Looks like the target property maps to the ImageView that FXMLBuilder injects for us in Java. With that info we can rewrite the above code:

(defn -onDragDetected [this e]
  (println "Drag: " e)
  (let [db (.startDragAndDrop (.getTarget e) TransferMode/ANY)
        content (ClipboardContent.)
        _ (.putString content "X")
        _ (.setContent db content)
        ]))

Again, we're just doing "dumb" translations, not really trying to clojure-ify anything. Our time will come, though, oh yes. (The rest of of blog VII was lamenting the fact that GridPane was really not very appropriate for the job, so let's roll straight on into VIII.)

The irony of FXML injecting that "X" and "O" field for us is that we ended up very quickly not using them. Who wants to write separate code for two essentially identical things? So we discover that having to do it "the hard way" actually makes things easier.

The issue with the GridPane was that we had no way to know where the mouse was in terms of our board. (Is it over the center? The top left?) So we forced the issue and came up with our own simple code to do just that:

public int getCoord(double width, double coord, int numberOfSections) {
    long dim = Math.round(width / numberOfSections);
    long border = dim;
    int val = 0;
    while(coord > border) {
        border += dim;
        val++;
    }
    return val;
}

It's simple but kind of dumb. We're doing a while-loop when a formula would've sufficed. Like, if we translate it into Clojure:

(defn get-coord [dim coord numberOfSections]
  (Math/min (quot coord (Math/round (float (/ dim numberOfSections)))) (dec numberOfSections)))

That's almost the same, anyway. If we had a tic-tac-toe board of 99 pixels, the Java code would return 33 as a 0 whereas this code will say it's a 1.

I could've done this in Java just as well, though at the time that borderline pixel issue seemed potentially problematic. But also, this is hard to read. Well, what if we formatted it better?

(defn get-coord [dim coord numberOfSections]
 (Math/min
    (quot coord
          (Math/round
            (float (/ dim numberOfSections))))
    (dec numberOfSections)))

Let's see, we're taking the minimum of the quotient of the mouse coordinate and the size of the section, and we're saying that is the column or row number, limited to how many columns or rows that there are. I guess that's not bad. What if we threaded it?

(defn get-coord [dim coord numberOfSections]
  (->> (float (/ dim numberOfSections))
      (Math/round)
       (quot coord)
       (Math/min
         (dec numberOfSections))))

Take the size of a section and round it, divide it into the mouse coordinate, then take the smaller of that and the number of column or rows. (Of course, the maximum addressable column or row is the number of columns or rows minus one, because we're using zero-based addressing.)

That's a very Clojure idiomatic way to do it. Honestly, I'm not crazy about it. How about this:

(defn get-coord [dim coord numberOfSections]
  (let [section-size (Math/round (float (/ dim numberOfSections)))
        section-number (quot (dec coord) section-size)]
    (Math/min section-number (dec numberOfSections))))

That seems nice and readable, and I could sneak in the (dec coord) so that it exactly matches the Java code without harming the readability.

Drag and Drop and Things

OK, now we need to accept the dragged item. Same deal: 1) Add a handler in the FXML,

<GridPane fx:id="board" gridLinesVisible="true" . . . onDragOver="#onDragOver">

2) Make the matching method in the Controller,

:methods [[onDragDetected [javafx.scene.input.MouseEvent] void]
            [onDragOver [javafx.scene.input.DragEvent] void]])

3) Make the function:

(defn -onDragOver [this e]
  (println "drag!"))

And now when you drag from the XOBin to the grid, you'll see "drag!" printed out repeatedly. Let's look at the Java code:

public void handleOnDragOver(DragEvent event) {
    int row = getCoord(board.getWidth(), event.getX(), board.getRowCount());
    int column = getCoord(board.getHeight(), event.getY(), board.getColumnCount());

    if (state[column][row]==null) {
        if (event.getGestureSource() != board && event.getDragboard().hasString()) {
            event.acceptTransferModes(TransferMode.COPY_OR_MOVE);
        }
    }
    event.consume();
}

Well, we have get-coord but we don't have a board! Java leans heavily on the FXML loader and its injection, more-or-less forcing you to make a class so that the FXML can inject into specific fields—which isn't really something we can do with a basic gen-class.

For now, we can cheat a bit:

(defn board[]
  (.lookup (transitions/get-node :ttt-grid) "#board"))

Getting the tic-tac-toe grid out of the nodes, give us the node named "#board". There are some issues with this, however. For one, it forces us to have only one tic-tac-toe game going at once since all controllers are going to be associated with the same :ttt-grid keyed object. We were kind of in that boat anyway, but it's a potential pitfall.

The bigger issue is that lookup gets a node based on its CSS class, which means CSS must have already been applied and the ID (in this case "#board") must be unique—and uniqueness is not enforced anywhere.

This will hold us for now, but it does seem increasingly like the whole Controller approach just isn't going to work out.

Let's actually open that board up a bit, and call it git, short for "get it":

(defn git [x]
  (.lookup (transitions/get-node :ttt-grid) (str "#" x)))

Now we can call get-coord:

(defn -onDragOver [this e]
  (let [board (git "board")
        col (get-coord (.getWidth board) (.getX e) 3)
        row (get-coord (.getHeight board) (.getY e) 3)]

Actually, we're gonna hafta repeat this step, so let's make a function:

(defn mouse-to-coord [e]
  (let [board (git "board")
        col (get-coord (.getWidth board) (.getX e) 3)
        row (get-coord (.getHeight board) (.getY e) 3)]
    [col row]))

And now we have:

  (let [[col row] (mouse-to-coord e)]

Which is much nicer. Before we can do the next step, we need some state:

(gen-class
  :name fxgamesc.ttt.TttController
  :init init
  :constructors {[] []}
  :state board
  :methods [[onDragDetected [javafx.scene.input.MouseEvent] void]
            [onDragOver [javafx.scene.input.DragEvent] void]])

The :state parameter is just a name for a field that's generated for the class—only one—which I've called board. It's initialized in the -init:

(defn -init []
  (println "Tic-tac-toe!")
  [[] (atom {})])

The first item returned is an empty vector which gets passed up to the superclass when needed, and the second is our atom. Given any this in a function, we can see the value like so:

(println (.board this))
#object[clojure.lang.Atom 0x169240d1 {:status :ready, :val {}}]

That's all we need to create Controller specific state, which we can set like so:

(defn set-cell [this x y v]
  (swap! (.board this) assoc [x y] v))

And we need a get-cell to progress in our drag-over detection:

(defn get-cell [this x y]
  (get (.board this) [x y]))

Now, going back to -onDragOver: Get the row and column: If there's nothing in the cell:

    (if-not (get-cell this col row)

And if the drag didn't start from the board and it has a string:

      (if (and (not= (.getGestureSource e) (git "board"))
               (.hasString (.getDragboard e)))

Yeah, I don't know why we wouldn't be looking for the source to specifically be the XOBin but we'll worry about that later. At this point, we're cleared to accept it.

        (.acceptTransferModes e TransferMode/COPY_OR_MOVE))))

And whatever happens, we consume the event.

  (.consume e))

All together:

(defn -onDragOver [this e]
  (let [[col row] (mouse-to-coord e)]
    (if-not (get-cell this col row)
      (if (and (not= (.getGestureSource e) board)
               (.hasString (.getDragboard e)))
        (.acceptTransferModes e TransferMode/COPY_OR_MOVE))))
  (.consume e))

This works pretty well.

Drop It Like It's Hot

To handle the drop, we'll need to do a few things. First, let's deal with the wiring. In the FXML:

<GridPane fx:id="board"...onDragOver="#onDragOver" onDragDropped="#onDragDrop">

And in the controller:

  :methods [[onDragDetected [javafx.scene.input.MouseEvent] void]
            [onDragOver [javafx.scene.input.DragEvent] void]
            [onDragDrop [javafx.scene.input.DragEvent] void]])

Now, to handle the drop:

OK, get the new row and column. In fact, let's sanity check and JUST get that:

(defn -onDragDrop [this e]
  (let [[col row] (mouse-to-coord e)]
    (println "DROP: " col "," row))

Looks good: Wherever we release the mouse, we get the right coords. Now, let's set the state:

(defn -onDragDrop [this e]
  (let [[col row] (mouse-to-coord e)
        _ (set-cell this col row "X")]
    (println "DROP: " col "," row)
    (println (.board this))))

Also looks good. The state updates as we drop.

DROP:  2 , 0
#object[clojure.lang.Atom 0x2526d284 {:status :ready, :val {[2 0] X}}]
DROP:  2 , 1
#object[clojure.lang.Atom 0x2526d284 {:status :ready, :val {[2 0] X, [2 1] X}}]
DROP:  1 , 2
#object[clojure.lang.Atom 0x2526d284 {:status :ready, :val {[2 0] X, [2 1] X, [1 2] X}}]

OK, let's create an image and add it to the board:

(defn -onDragDrop [this e]
  (let [[col row] (mouse-to-coord e)
        _ (set-cell this col row "X")
        xo (ImageView. (.getImage (git "X")))
        board (git "board")]
    (.addAll (.getChildren board) [xo])))

OK, big sloppy "X", as we'd expect.

image.png

OK, now let's fit the "X":

(defn -onDragDrop [this e]
  (let [[col row] (mouse-to-coord e)
        _ (set-cell this col row "X")
        xo (ImageView. (.getImage (git "X")))
        board (git "board")]
    (.setFitWidth xo (/ (.getWidth board) 3))
    (.setFitHeight xo (/ (.getHeight board) 3))
    (.addAll (.getChildren board) [xo])))

Which gives us:

image.png

OK, now let's put it in the right location:

(defn -onDragDrop [this e]
  (let [[col row] (mouse-to-coord e)
        _ (set-cell this col row "X")
        xo (ImageView. (.getImage (git "X")))
        board (git "board")]
    (.setFitWidth xo (/ (.getWidth board) 3))
    (.setFitHeight xo (/ (.getHeight board) 3))
    (GridPane/setRowIndex xo row)
    (GridPane/setColumnIndex xo col)
    (.addAll (.getChildren board) [xo])))

Whoops! We get nothing. Well, on the REPL, we see:

image.png

Which translates to:

J a v a   M e s s a g e : j a v a . l a n g . r e f l e c t . I n v o c a t i o n T a r g e t E x c e p t i o n

Not very helpful. And when I try to debug in IntelliJ, the error actually pops up on the previous line. And the error seems to get thrown in the FXMLLoader of all classes, which I thought we were done with.

Now, just about every time I've made an error, this is what has happened: The debugger doesn't seem to behave the way I expect it, variables will suddenly become null, and obviously not wrong code (like going over an if statement) will trigger it.

Up till now we've had pretty smooth sailing, but this is not fun. And the issue isn't necessarily going to be obvious, at least until we've taken our lumps for a while. The crux of that java.lang.reflect.InvocationTargetException is that we're trying to do something with the wrong type.

In this case, col and row. They're Long and GridPane wants...an Integer. Oy.

    (GridPane/setRowIndex xo row)
    (GridPane/setColumnIndex xo col)

We could do this:

    (GridPane/setColumnIndex xo (Integer/valueOf col))

But let's update our get-coord instead:

(defn get-coord [dim coord numberOfSections]
  (let [section-size (Math/round (float (/ dim numberOfSections)))
        section-number (int (quot (dec coord) section-size))]
    (Integer/valueOf (min section-number (dec numberOfSections)))))

We don't care what kind of integer we get in Clojure but Java cares a whole lot. It cares a whole lot about a lot of things Clojure doesn't care about.

OK, let's wrap this up (original blog entry VIII) by putting the right thing into the cell. We'll go back to onDragDetected:

(defn -onDragDetected [this e]
  (let [db (.startDragAndDrop (.getTarget e) TransferMode/ANY)
        content (ClipboardContent.)
        _ (.putString content (.getId (.getSource e)))
        _ (.setContent db content)
        ]))

The magic is:

(.getId (.getSource e))

The source is the place where the drag started, and the ID will be either "X" or "O", so we can just put that right in. First we'll fetch it:

(defn -onDragDrop [this e]
  (let [[col row] (mouse-to-coord e)
        pc (.getString (.getDragboard e))

Make sure the state gets set properly:

        _ (set-cell this col row pc)

And now set the image:

        xo (ImageView. (.getImage (git pc)))

Voila!

Our Tic-Tac-Toe controller is a whopping 65 lines long, which is a lot, really, but we're doing a very "square" translation from the Java. It's readable code, though, with only a couple of ugly technical debts we'll have to pay off, and we learned a lot about where the pitfalls in Clojure using JavaFX will be.

Source is here.