In this tutorial, we will work through several more examples of FRP in Elm. Our motivating application will be building an integer counter that can be updated with buttons.
We will start with the application State
:
type alias State = Int
type CounterEvent = Inc | Clear
initState = 0
upstate e i = case e of
Clear -> 0
Inc -> i + 1
To warm up the development process, we will first define main
in terms of a dummy constant signal.
stateOverTime : Signal State
stateOverTime = Signal.foldp upstate initState (Signal.constant ())
main = Signal.map2 view stateOverTime Window.dimensions
And, to set up the basis for our GUI, we start with a simple rendering function that writes the current value of the counter to the middle of the screen. We bind some libraries to single letter names for brevity (e.g. E
for Graphics.Element
, T
for Text
, and C
for Graphics.Collage
). Notice how E.container
is used to define a sized Element
with content placed in a particular Position
within, and E.flow
places a list of Elements
next to one another in a particular Direction
.
view i (w,h) =
let caption = i |> toString |> T.fromString |> E.leftAligned in
E.color Color.gray
<| E.container w h E.middle
<| E.flow E.down [ caption ]
Underneath the counter display, we want to draw two buttons, one for incrementing and one for clearing. The Graphics.Input
library offers the following to build buttons:
button : Message -> String -> Element
What is a Message
(defined in Signal
)? This type describes a value that gets sent to a particular Mailbox
(also defined in Signal
). Okay, so what is a Mailbox
?
Mailboxes provide a way to create "pipes" through which messages (of a specified type) can be sent from one part of an Elm application to another. They are created by the function:
mailbox : a -> Mailbox a
I like to think of mailboxes as "dynamically generated signals." But why is there a need for a separate mechanism?
In the programming we have done so far, we start with the bunch of primitive signals that Elm provides, and then we derive other signals in terms of them. But these derived signals do not generate new kinds of events or messages. Instead, an entire program can be thought of as propagating the updates to primitive signals. This program structure is exhibited by the acylic nature of signal graphs.
But what if the programmer wants to define new signals? For example, say the programmer wants to draw n buttons to the screen, each of which should have its own events, such as Mouse.clicks
only that it is "local" to the button rather the entire window. Well, if the programmer knew exactly what the number n is, and if Elm provided enough separate button signals (Button1.clicks
, Button2.clicks
, Button3.clicks
, etc.), then these primitive signals could be used. But what if this number is large, and what if it's not known before running the program? For this, we need a way to dynamically generate signals.
Mailboxes are the solution that Elm provides. They are a separate mechanism than signals, and they are impure. Every time the mailbox
function is invoked, it creates a new pipe in the world that can be used by application components to communicate.
Gasp! We are already breaking outside of the purely functional model? Other, more expressive formulations of FRP do not require a separate mechanism like this. In these "higher-order" FRP systems, signals of signals can be used to encode dynamically generated signals. But along with this expressiveness comes other challenges, both in design and efficient implementation, that are the subject of ongoing research.
In contrast, Elm strikes a balance of "first-order" FRP that prohibits signals of signals, and instead provides this separate mailbox mechanism for the common case of dynamically generated buttons and HTML elements that need to respond to typical browser events. Part of the novelty in Elm is that it provides an increasingly practical implementation of this language design choice. Time will tell whether the balance it strikes is a good fit!
A Mailbox
is actually a record with two components:
type alias Mailbox a =
{ address : Address a
, signal : Signal a
}
The first is a unique Address
for this Mailbox
(assigned internally when calling the mailbox
function), and the second is a Signal
with all of the Message
s (in particular, the values of type a
) that are sent to this Mailbox
.
Finally, to construct a Message
to send:
message : Address a -> a -> Message
Notice that the type Message
does not reveal which Address
it can be sent to; this information is internalized, or encapsulated, in the Message
.
Going back to our example, we need to create a Mailbox
for our button
to send
Message
s to.
buttonMailbox : Mailbox CounterEvent
buttonMailbox = mailbox Clear
Now we can use button
to specify the Message
s to send:
incButton = button (Signal.message buttonMailbox.address Inc) "Increment"
clearButton = button (Signal.message buttonMailbox.address Clear) "Clear"
Then, we'll draw the counter and two button
s separated by some space (using List.intersperse
and E.spacer
).
vspace = E.spacer 0 10
view i (w,h) =
let caption = i |> toString |> T.fromString |> E.leftAligned in
E.color Color.gray
<| E.container w h E.middle
<| E.flow E.down
<| List.intersperse vspace [ caption, incButton, clearButton ]
Finally, we tie our Signal State
to the values sent to our Mailbox
:
stateOverTime = Signal.foldp upstate initState buttonMailbox.signal
Now we have a working counter! Check out the source and the demo.
Having gotten the basic functionality of our counter working, let's now make it look better. The function
customButton : Message -> Element -> Element -> Element -> Element
in Graphics.Input
takes three Elements
to draw depending on whether the button is up, hovered over, or down, respectively. We can make use of this to use different colors for each.
myButton msg s =
let b = button msg s in
customButton msg
(E.color Color.lightYellow b)
(E.color Color.lightOrange b)
(E.color Color.lightBlue b)
Calling myButton
instead of button
in view
then renders our slightly fancier buttons. This is cool, but the default styles are still not very pretty. It would be nice to have borders around the buttons and also change the font style to make it more readable. Looking through the type signatures in Graphics.Input
, Graphics.Element
and Text
, there do not seem to be library functions that will do exactly what we want. So, we'll have to work with a lower-level "unstructured" graphics library.
Graphics.Element
has many useful layout functions, but if we want to do more freeform drawing, we need to use the Graphics.Collage
library. The working type is a Form
and there are many ways to create them.
Functions like circle
and rect
create Shape
values.
circle : Float -> Shape
rect : Float -> Float -> Shape
How do we convert Shape
s to Form
s? Searching the documentation for the type signature "Shape -> Form
" reveals several functions, such as filled
and outlined
.
filled : Color -> Shape -> Form
outlined : LineStyle -> Shape -> Form
Once we are done building some Form
s, how do we convert them to Element
s so that they can be rendered? Ah, searching for the type signature "Form -> Element
" reveals the following:
collage : Int -> Int -> List Form -> Element
And what if we have an Element
that we want to mix into an unstructured graphics canvas. Searching for "Element -> Form
" reveals:
toForm : Element -> Form
Let's now put some of these tools to use in order to style our counter buttons. First, let's use the Text
library to format strings more nicely.
strStyle : String -> E.Element
strStyle = T.fromString >> T.height 30 >> E.centered
Next, let's define a custom line style for drawing borders rather than the default one provided called defaultLine
. These values are described by the record type LineStyle
that contains several parameters. We only want to customize a subset of these parameters, so we can use record update syntax to create a new record that is just like defaultLine
except for a couple changes. If we try the following...
lineStyle = { C.defaultLine | color = Color.darkCharcoal, width = 10 }
we get a syntax error. The issue is that the parser (by design) requires the record being copied and updated to syntactically be a variable, not an identifier qualified by modules like C.defaultLine
. No matter, there are two very simple workarounds. One option:
lineStyle =
let defaultLine = C.defaultLine in
{ defaultLine | color = Color.darkCharcoal, width = 10 }
Another option is to modify the import
:
import Graphics.Collage as C exposing (defaultLine)
We will now draw custom buttons, but we will define each of the three Elements
using collages rather than ordinary buttons like before.
btnW = 200
btnH = 60
myButton msg s =
let drawButton c =
C.collage btnW btnH
[ C.filled c (C.rect btnW btnH)
, C.outlined lineStyle (C.rect btnW btnH)
, strStyle s |> C.toForm
]
in
customButton msg
(drawButton Color.lightYellow)
(drawButton Color.lightOrange)
(drawButton Color.lightBlue)
The myButton
function draws a button in three steps: first, the solid color, then the border, and then the button label. The list of Form
s passed to collage
is interpreted to be in increasing z-order, meaning each successive element is rendered on top of the previous. As a result, we must put the label after the solid rectangle so that it does not get hidden.
As a final tweak, let's change the style of the counter display to match our buttons. So that the display is centered, we create a container of the same width as the buttons and center the text within it.
...
let caption = i |> toString |> strStyle |> E.container btnW btnH E.middle in
...
Our counter is now much more stylish than before! Check out the source and the demo.
Minor note about syntax: When writing an expression like a list literal on a new line ([ C.filled ...]
above), the parser may complain if there are fewer than three spaces more than the line above it. Try two spaces and observe the error for future reference.
From the documentation of Signal.mailbox
: "Creating new signals [via mailboxes] is inherently impure." So dynamically generating buttons dynamically affects the signal graph (particularly, creating new kinds of input to the graph), breaking the purely functional model. Some of you may not have been able to fully enjoy our button example in light of this. If you have been wondering to yourself whether there is some other way, I applaud you for your purity when it comes to purity.
There is an alternative to using channels to define buttons in Elm, but it comes with other major downsides. The idea is to use only primitive mouse signals, and keep track of where buttons are in the window manually in our own code. In other words, we can roll our own little event system and renderer. This approach will throw out many of the benefits provided by the abstractions in Graphics.Element
. But, we could follow this approach if we are really determined. We will work through part of what it would is required to implement the counter application using "pure" buttons.
Whenever the mouse is clicked, we need to determine whether its position is within a "button" or not. So we will have to keep track of the button's boundaries (in pixels). In addition to the counter, let's display the position of the last click for development purposes.
type alias Pt = (Int, Int)
type alias State = (Pt, Int)
initState = ((-1,-1), 0)
The positions reported by Mouse.positions
treat (0,0)
as the upper-left corner of the window, so we use negative x- and y-values to denote the initial state when there have been no clicks.
For simplicity, let's position the counter display and buttons in the upper-left corner one on top of the other with no space in between. This will simplify our pixel calculuations. After defining the width and height of our display and buttons, we can then update the counter in State
only if the click appears within the boundaries of a "button."
btnW = 200
btnH = 60
upstate : Pt -> State -> State
upstate (x,y) (_,i) =
let inPlace i = x <= btnW && y >= i * btnH && y < (i+1) * btnH in
let j = case i of
1 -> i + 1 -- Increment "button"
2 -> 0 -- Clear "button"
_ -> i -- elsewhere
in
((x,y), j)
We then set up the controller to trigger updates based on changes in the most recent mouse click position.
clickPositions : Signal Pt
clickPositions = Signal.sampleOn Mouse.clicks Mouse.position
stateOverTime : Signal State
stateOverTime = Signal.foldp upstate initState clickPositions
main = Signal.map view stateOverTime
Or all in one:
main =
Signal.map view
(Signal.foldp upstate initState
(Signal.sampleOn Mouse.clicks Mouse.position))
Lastly, the view. When using the Graphics.Collage
library, it is important to realize that the coordinate system for collage
is centered in the middle of the collage, and move
(supplied with positive arguments) translates Shapes
in a collage up and to the right. (You probably discovered this on your own while working through Homework 1.)
Here's a really quick-and-dirty rendering of the counter and buttons that takes these two factors into account.
strStyle : String -> E.Element
strStyle = T.fromString >> T.height 30 >> E.centered
view : State -> E.Element
view st =
let rows = 3
w = btnW
h = rows * btnH
place i = C.moveY (btnH - (i-1)*btnH)
rect c = C.filled c (C.rect btnW btnH)
text c s = C.toForm <| color c <| strStyle s
in
C.collage w h
[ place 1 <| rect Color.lightGray
, place 1 <| text Color.lightGray <| toString st
, place 2 <| rect Color.lightYellow
, place 2 <| text Color.lightYellow "Increment"
, place 3 <| rect Color.lightOrange
, place 3 <| text Color.lightOrange "Clear"
, C.outlined C.defaultLine (C.rect w h)
]
Here is the source and demo. For fun, you may want to continue down this path and implement the entire application from before in the Mailbox
-less style. If you do, take a moment to reflect on whether your views have become any less pure.