Search code examples
scalauser-interfacejavafxscalafx

ScalaFX - How to get the title of a Scene using a method


I am using ScalaFX and trying to learn how it works. As an exerpiment (not what I will do in production) I want a method that gets the title of a window.

So here is my Graph.scala file:

package graphing

import scalafx.application.JFXApp
import scalafx.scene.Scene
import scalafx.scene.paint.Color

class Graph {
  val app = new JFXApp {
    stage = new JFXApp.PrimaryStage {
      title = "First GUI"
      scene = new Scene {
        fill = Color.Coral
      }
    }
  }

  def getTitle() = {
    app.stage.getTitle
  }

  def generateChart(args: Array[String]) = app.main(args)
}

Here is my driver object that makes use of this Graph class:

package graphing

import graphing.Graph

object Driver extends App {
  val graph = new Graph

println(graph.getTitle())

  graph.generateChart(args)
}

However, this does not work due to the line

println(graph.getTitle())

The error that is thrown: enter image description here

Could someone kindly explain what is going on and how I can achieve my goal here?


Solution

  • The problem here concerns JavaFX (and hence, ScalaFX) initialization.

    Initializing JavaFX is a complex business. (Indeed, I only recently learned that it was even more complicated than I originally believed it to be. Refer to this recent answer here on StackOverflow for further background. Fortunately, your problem is a little easier to resolve.)

    ScalaFX simplifies JavaFX initialization greatly, but requires that the JFXApp trait be used as part of the definition of an object.

    JFXApp contains a main method, which must be the starting point of your application; it is this method that takes care of the complexities of initializing JavaFX for you.

    In your example, you have your Driver object extend scala.App, and so it is App's (and hence, Driver's) main method that becomes the starting point of your own application. This is fine for a regular command line interface (CLI) application, but it cannot be used with ScalaFX/JavaFX applications without a great deal of additional complexity.

    In your code, JFXApp's main method never executes, because, as it is defined as a class member, it is not the main method of a Scala object, and so is not a candidate for automatic execution by the JVM. You do call it manually from your Graph.generateChart() method, but that method itself is not called until after you try to get the title of the scene, hence the NPE as the stage has not yet been initialized.

    What if you put the graph.generateChart(args) call before the println(graph.getTitle()) statement? Will that fix it? Sadly, no.

    Here's why...

    JFXApp also performs one other bit of magic: it executes the construction code for its object (and for any other classes extended by that object, but not for extended traits) on the JavaFX Application Thread (JAT). This is important: only code that executes on the JAT can interact directly with JavaFX (even if through ScalaFX). If you attempt to perform JavaFX operations on any other thread, including the application's main thread, then you will get exceptions.

    (This magic relies on a deprecated Scala trait, scala.DelayedInit, which has been removed from the libraries for Scala 3.0, aka Dotty, so a different mechanism will be required in the future. However, it's worth reading the documentation for that trait for further background.)

    So, when Driver's construction code calls graph.generateChart(args), it causes JavaFX to be initialized, starts the JAT, and executes Graph's construction code upon it. However, by the time Driver's constructor calls println(graph.getTitle()), which is still executing on the main thread, there are two problems:

    1. Graph's construction code may, or may not, have been executed, as it is being executed on a different thread. (This problem is called a race condition, because there's a race between the main thread trying to call println(graph.getTitle()), and the JAT trying to initialize the graph instance.) You may win the race on some occasions, but you're going to lose quite often, too.
    2. You're trying to interact with JavaFX from the main thread, instead of from the JAT.

    Here is the recommended approach for your application to work:

    package graphing
    
    import scalafx.application.JFXApp
    import scalafx.scene.Scene
    import scalafx.scene.paint.Color
    
    object GraphDriver
    extends JFXApp {
    
      // This executes at program startup, automatically, on the JAT.
      stage = new JFXApp.PrimaryStage {
        title = "First GUI"
        scene = new Scene {
          fill = Color.Coral
        }
      }
    
      // Print the title. Works, because we're executing on the JAT. If we're NOT on the JAT,
      // Then getTitle() would need to be called via scalafx.application.Platform.runLater().
      println(getTitle())
    
      // Retrieve the title of the stage. Should equal "First GUI".
      //
      // It's guaranteed that "stage" will be initialized and valid when called.
      def getTitle() = stage.title.value
    }
    

    Note that I've combined your Graph class and Driver object into a single object, GraphDriver. While I'm not sure what your application needs to look like architecturally, this should be an OK starting point for you.

    Note also that scala.App is not used at all.

    Take care when calling GraphDriver.getTitle(): this code needs to execute on the JAT. The standard workaround for executing any code, that might be running on a different thread, is to pass it by name to scalafx.application.Platform.runLater(). For example:

    import scalafx.application.Platform
    
    // ...
    Platform.runLater(println(ObjectDriver.getTitle()))
    // ...