scalafunctional-programmingreactive-programmingscalafx

Handle input events in stateless manner


Yesterday I`ve asked how may I handle keyboard input (with ScalaFX) in a functional manner. Thanks to @alfilercio help I came up with something like this:

class InputHandler {
  private val goUp: Int => State => State = step => state => State(state.x, state.y - step)
  private val goDown: Int => State => State = step => state => State(state.x, state.y + step)
  private val goLeft: Int => State => State = step => state => State(state.x - step, state.y)
  private val goRight: Int => State => State = step => state => State(state.x + step, state.y)
  private val doNothing: Int => State => State = step => state => state

  private var behaviour: Int => State => State = doNothing

  def keyPressedHandler(key: KeyEvent): Unit = {
    behaviour = key.getCode match {
      case KeyCode.Up.delegate => goUp
      case KeyCode.Down.delegate => goDown
      case KeyCode.Left.delegate => goLeft
      case KeyCode.Right.delegate => goRight
      case _ => doNothing
    }
  }

  def keyReleasedHandler(key: KeyEvent): Unit = behaviour = doNothing

  def update: Int => State => State = behaviour
}

Then there is an Updater (working name) thats updates state based on time passed, some internal logic and/or user input:

def update(state: State)(implicit inputHandler: InputHandler): State = { ... }

With such approach the core classes may remain pure and no single variable is needed. But there is still problem with the InputHandler itself. I mean the behaviour variable makes it statefull. This InputHandler adds kind of abstraction to ScalaFX used to generate GUI. The metdods keyPressedHandler/keyRelasedHandler are set as ScalaFX Scene events handlers respectively. To conclude, I am looking way to remove state variable (behaviour) from this InputHandler. I try to grasp functional approach for educational reasons that`s why I keep bothering you with this case :)


Solution

  • Personally I would assume that all Listeners and Handlers are by definition impure objects from outside of our pure world, so if I wanted to keep things pure, I would make them send commands as values through some IO.

    class SthListener(keyPressed: KeyCode => Unit,
                      keyReleased: KeyCode => Unit,
                      updateState: (State => Unit) => Unit) externds XListener {
    
      def keyPressedHandler(key: KeyEvent): Unit = keyPressed(key.getCode)
    
      def keyReleasedHandler(key: KeyEvent): Unit = keyReleased()
    
      def update: Unit = updateState { state => // received from outside world
        // how to update current component basing on received state
      }
    }
    

    and somewhere else

    sealed trait Event
    object Event {
      case class KeyPressed(keyCode: Int) extends Event
      case class KeyReleased(keyCode: Int) extends Event
    }
    
    val eventBus = Queue[Task, Event]
    
    val stateRef = Ref[Task, State]
    
    // translate pure operations on boundary of dirtyness
    new SthListener(
      keyPressed = keyCode => eventBus.enqueue1(Event.KeyPressed(keyCode)).runSync, // or runAndForget, or what works for you,
      keyReleased = keyCode => eventBus.enqueue1(Event.KeyReleased(keyCode)).runSync,
      update => stateRef.get.runSync
    )
    
    // handle state transitions
    queue.dequeue
      .evalMap {
        case Event.KeyPressed(key)  => stateRef.update(...)
        case Event.KeyReleased(key) => stateRef.update(...)
      }
      .compile
      .drain
    

    Does it make sense doing it always? Personally I don't think so, but you/your team see much value in purity, RT, and you have a lot of use cases which would justify this overhead, then you could add this kind of wrappers to make sure that imperative API won't force you to change the style of coding in whole application, but just in the part that forces imperative style on you.

    If this is way too much... well, you can just use mutability and imperative style for now and revisit that approach once you will feel more familiar. Don't force it, take your time and write the code that the current you can understand and maintain easily.