Porting a Module to Elm 0.17

Elm 0.17 has been out for a little while now; it has a slightly different way of doing things compared to 0.16 and before. Like many others, I started porting my existing applications to 0.17 - first I started with my only “production” application, elm typing tutor, which was pretty trivial to update. After that, I moved onto the sample code I wrote for a talk I gave here in Chicago about Elm, which was also pretty simple. Last weekend, I took it upon myself to update my last remaining body of 0.16 code - the first application I wrote in Elm, one I've been keeping up-to-date since 0.14. I haven't released this code publicly yet; but suffice to say it's an application with a coordinate grid that you can click and drag to scroll around. The drag event functionality is encapsulated in its own module, and porting it to 0.17 was a little more challenging than before, so I thought I'd talk about how that went!

Drag.elm - 0.16 and earlier

First things first - here's what Drag.elm looks like as of Elm 0.16:

module Drag where

import Mouse
import Signal

type alias MouseState = {
    isDown : Bool,
    wasDown : Bool,
    prevPosition : (Int, Int),
    currPosition : (Int, Int)

type alias DragDistance = (Int, Int)

mouseState : Signal MouseState
mouseState =
    Signal.foldp (\(isDown, newPos) oldState -> {
        isDown = isDown,
        currPosition = newPos,
        wasDown = oldState.isDown,
        prevPosition = oldState.currPosition
    }) { isDown = False, wasDown = False, prevPosition = (0, 0), currPosition = (0, 0) }
    <| Signal.map2 (\a b -> (a, b)) Mouse.isDown Mouse.position

mapDragState : MouseState -> DragDistance
mapDragState ms =
   if ms.isDown && ms.wasDown
       then let (currX, currY) = ms.currPosition
                (oldX, oldY)   = ms.prevPosition
                (currX - oldX, currY - oldY)
       else (0, 0)

drag : Signal DragDistance
drag =
    Signal.map mapDragState mouseState

It's not a lot of code, and it's pretty easy to follow, I think. My favorite part about how this module works in 0.16 is that it exposes two things you need to care about - DragDistance, which is the distance the mouse has been dragged since the previous event, and drag, a stream of drag events. It's extremely simple to integrate into your code; just consume the drag signal and handle it in your update function.

Updating to 0.17 - What's Changed?

Even before 0.17, most modules like Drag.elm resemble the Elm architecture: you have initialization, events you care about, and updating your state according to events. Applications have a view, which isn't really needed for modules.

With 0.17, modules like this one resemble the Elm architecture even more, because now things like Signal.foldp don't exist. It's up to the application developer to call the module's initialization, update, and subscription routines. Another change is that Signal is gone, replaced by Sub and Cmd, which correspond to the types of the events you're interested in, and the events themselves, respectively.

Cmd and Conquer

For this module, I decided to expose an additional event type in addition to the internal events that drag needs to update its state; I wanted the consuming application to get a Drag event whenever a drag occurred. To do that, I needed the module's update function to create a Cmd. There's no function in Elm 0.17 to simply create a Cmd, so this was my first stumbling block. Fortunately, the Elm Slack channel is always helpful, and szabba provided me with a snippet of code that did just that: Task.perform (always <| Drag (dx, dy)) (always <| Drag (dx, dy)) Time.now

The Finished Product

Here's what the finished module looks like, with module documentation omitted for brevity:

module Drag exposing (Model, Msg, initialModel, subscriptions, update)

import Task
import Time

import Mouse

type alias Model = {
    isDown : Bool,
    currPosition : (Int, Int)

type Msg =
  MouseUp   Mouse.Position |
  MouseDown Mouse.Position |
  MouseMove Mouse.Position

initialModel : Model
initialModel = {
    isDown = False,
    currPosition = (0, 0)

subscriptions : (Msg -> msg) -> Model -> Sub msg
subscriptions constructor model =
  let ups   = Mouse.ups <| constructor << MouseUp
      downs = Mouse.downs <| constructor << MouseDown
      moves = Mouse.moves <| constructor << MouseMove
      subs = if model.isDown then [ ups, downs, moves ] else [ downs ]
  in Sub.batch subs

dragCmd : ((Int, Int) -> msg) -> (Int, Int) -> (Int, Int) -> Cmd msg
dragCmd constructor (px, py) (cx, cy) =
  let dx = px - cx
      dy = py - cy
      task = always <| constructor (dx, dy)
  in Task.perform task task Time.now

update : ((Int, Int) -> msg) -> Msg -> Model -> (Model, Cmd msg)
update constructor msg model =
  case msg of
    MouseUp _        -> ({ model | isDown = False }, Cmd.none)
    MouseDown {x, y} -> ({ isDown = True, currPosition = (x, y) }, Cmd.none)
    MouseMove {x, y} ->
      let newModel = { model | currPosition = (x, y) }
          cmd = if model.isDown
            then dragCmd constructor model.currPosition (x, y)
            else Cmd.none
      in (newModel, cmd)

And here's some example code that uses it:

import Html.App as App
import Html exposing (Html, text)

import Drag

type alias Model = {
    dragModel : Drag.Model,
    dragDistance : Int

type Msg =
  DragMsg Drag.Msg |
  Drag (Int, Int)

init : (Model, Cmd Msg)
init =
  let initialModel = {
    dragModel = Drag.initialModel,
    dragDistance = 0
  } in (initialModel, Cmd.none)

subscriptions : Model -> Sub Msg
subscriptions model = Drag.subscriptions DragMsg model.dragModel

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
  case msg of
    DragMsg msg ->
      let (newDragModel, dragCmd) = Drag.update Drag msg model.dragModel
      in ({model | dragModel = newDragModel}, dragCmd)
    Drag (dx, dy) -> ({ model | dragDistance = model.dragDistance + (abs dx) + (abs dy) }, Cmd.none)

view : Model -> Html Msg
view model = text <| toString model

main : Program Never
main = App.program {
    init = init,
    update = update,
    subscriptions = subscriptions,
    view = view

You may have noticed that I highlighted a few lines in Main.elm; I wanted to talk about what I feel is a weakness of 0.17 compared to previous versions of Elm. The highlighted lines all have something in common: they are all spots in the main application code that need to concern themselves with how the dragging module works. Before, Drag.elm took care of its own intialization, event subscriptions, and updating; but now, that responsibility falls to the user.

UPDATE - 2016-06-15

Ahri on Reddit asked me to demonstrate what the code for Main.elm would look like in 0.16, so here it is:

import Drag
import Html exposing (Html, text)

initialState : Int
initialState = 0

view : Int -> Html
view dragDistance = text <| toString dragDistance

update : (Int, Int) -> Int -> Int
update (dx, dy) dragDistance =
  dragDistance + (abs dx) + (abs dy)

main : Signal Html
main = Signal.map view <| Signal.foldp update initialState Drag.drag


Elm 0.17 is great for lowering the barrier to entry to the language, but I'm afraid that it may make things harder on authors of modules like this one, and I'm afraid of the increased boilerplate that consuming these modules requires. “Official” modules like Random don't need this; you don't need to help them set themselves up or manage their state. A quick peek at the code reveals something called an effect module, but I'm guessing that's an internal concept that the core team isn't quite ready for the world to see.

I know that 0.17 and the ideas introduced with it are still very fresh; I'm confident that the core Elm team will come up with ways for authors to write their own event modules in a way similar to Random, keeping Elm easy and fun for module and application authors alike!