I've been doing some GUI coding recently using a combination of Reactive Banana and GTK3. I started out with just GTK3, but I could see it wasn't going to scale because everything GTK3 does is in the IO monad. I found I was having to create IORefs to track the application state, and then pass these around for reading and writing by various event handlers. While the application was small this was manageable, but I could see that it was going to grow into a pile of imperative spaghetti as time went on.
I knew about functional
reactive programming (FRP), and went on the hunt for a framework
that would work with GTK3. I chose
Reactive
Banana despite the silly name because it seemed to be targeted at
desktop GUI applications rather than games and simulations.
Connecting to GTK3
FRP is based around two key abstractions:
Events are instants in time that
carry some data. When the user clicks on a button in a GUI you want
an event to denote the fact that the button was clicked. If the user
moves a slider then you want an event with the new position of the
slider.
Behaviors carry data that changes over time. In theory this
change can be continuous; for instance if you simulate a bouncing
ball then the position of the ball is a behavior; at any point in
time you can query it, and queries at different times will get
different answers. However Reactive Banana only supports behaviors
that change in response to events and remain constant the rest of
the time. Thats fine: my application responds to user events and
doesn't need continuous changes. Take the slider event I mentioned
above: when the user moves the slider you want to update a
sliderPosition
behavior with the latest position so that other parts of the program
can then use the value later on.
In Reactive Banana you can convert an event into a behavior with the "stepper" function. You can also get an event back when a behavior changes
Behaviors are a lot like the IORefs I was already using, but events are what make the whole thing scalable. In a large application you may want several things to happen when the user clicks something, but without the Event abstraction all of those things have to directly associated. This harms modularity because it creates dependencies between modules that own callbacks and modules that own widgets, and also causes uncertainty within callbacks about which other callbacks might already have been invoked. With FRP the widget creator can just return an Event without needing to know who receives it, and behaviours are not updated until all events have been executed.
There is already a binding between Reactive Banana and WxHaskell, but nothing for GTK. So my first job was to figure this out. Fortunately it turned out to be very simple. Every widget in GTK3 has three key lists of things in its API:
IO functions. These are used to create widgets, and also to get and set various parameters. So for instance the slider widget has functions like this (I'm glossing over some typeclass stuff here. For now just take it that a slider has type Range):
rangeGetValue :: Range -> IO Double
rangeSetValue :: Range -> Double -> IO ()
Attributes. These are kind of like lenses on the widget, in that they let you both read and write a value. However unlike Haskell lenses this only works in the IO monad. So for instance the slider widget has an attribute:
rangeValue :: Attr Range Double
You can access the attributes of a widget using the get and set functions. This is equivalent to using the two IO functions above.
Signals. These are hooks where you can attach a callback to a widget using the
on
function. A callback is an IO monad action which is invoked
whenever the signal is triggered. This is usually when the user does
something, but can also be when the program does something. For
instance the slider widget has a signal
valueChanged :: Signal Range (IO ())
The last argument
is the type of the callback. In this case it takes no parameters and
returns no value, so you can hook into it like this:
on mySlider valueChanged $ do
v <- rangeGetValue mySlider
One subtlety about GTK3 signals is that they are often only triggered when the underlying value actually changes, rather than every time the underlying setter function is called. So if the slider is on 10 and you call "rangeSetValue 9" then the callback is triggered in exactly the same way as when the user moves it. However if you call "rangeSetValue 10" then the callback is not triggered. This lets you cross-connect widgets without creating endless loops.
Connecting GUI Inputs
The crucial thing is that GTK signals and attributes are isomorphic with Reactive Banana events and behaviors. So the following code gets you quite a long way:
registerIOSignal :: (MonadIO m) =>
object
-> Signal object (m a)
-> m (a, b)
-> MomentIO (Event b)
registerIOSignal obj sig act = do
(event, runHandlers)
liftIO $ obj `on` sig $ do
(r, v) <- act
liftIO $ runHandlers v
return r
return event
There are a few wrinkles that this has to cope with:
First, a few signal handlers expect the callback to return something other than "()". Hence the "a" type parameter above.
Second, the callback doesn't usually get any arguments, such as the current slider position. Its up to the callback itself to get whatever information it needs. Hence you still need to write some callback code.
Third, some signals work in monads other than just "IO". Usually these are of the form "ReaderT IO" (that is, IO plus some read-only context). The "m" type parameter allows for this.
So now we can get a Reactive Banana event for the slider like this:
sliderEvent <- registerIOSignal mySlider valueChanged $ do
v <- rangeGetValue mySlider
return ((), v)
The two values in the "return" are the return value for the callback (which is just () in this case) and the value we want to send out in the Event.
Some signals do provide parameters directly to the callback, so you need a family of functions like this:
registerIOSignal1 :: (MonadIO m) =>
object
-> Signal object (a -> m b)
-> (a -> m (b, c))
-> MomentIO (Event c)
registerIOSignal2 :: (MonadIO m) =>
object
-> Signal object (a -> b -> m c)
-> (a -> b -> m (c, d))
-> MomentIO (Event d)
And so on up to registerIOSignal4, which is the longest one I have needed so far.
Connecting Outputs
Outputs are simpler than inputs. Reactive Banana provides a function for linking an event to an IO action:
reactimate :: Event (IO ()) -> MomentIO ()
This takes an event carrying IO actions and executes those actions as they arrive. The "MomentIO" return value is the monad used for building up networks of events and behaviors: more of that in "Plumbing" below.
Events are functors, so the usual pattern for using reactimate looks like this:
reportThis :: Event String -> MomentIO ()
reportThis ev = do
let ioEvent = fmap putStrLn ev
reactimate ioEvent
The argument is an event carrying a string. This is converted into an event carrying IO actions using "fmap", and the result is then passed to reactimate. Obviously this can be reduced to a single line but I've split it out here to make things clearer.
So we can link an event to a GTK attribute like this:
eventLink :: object -> Attr object a -> Event a -> MomentIO ()
eventLink obj attr =
reactimate . fmap (\v -> set obj [attr := v])
Whenever the argument event fires the attribute will be updated with the value carried by the event.
Behaviors can be linked in the same way. Reactive Banana provides the "changes" function to get an event whenever a behavior might have changed. However this doesn't quite work the way you would expect. The type is:
changes :: Behavior a -> MomentIO (Event (Future a))
The "Future" type reflects the fact that a behavior only changes after the event processing has finished. This lets you write code that cross-links events and behaviors without creating endless loops, but it means you have to be careful when accessing the current value of a behavior. More about this in "Plumbing" below.
To cope with these "Future" values there is a special version of "reactimate" called "reactimate' " (note the tick mark). You use it like this:
behaviorLink :: object -> Attr object a -> Behavior a -> MomentIO ()
behaviorLink obj attr bhv = do
fe <- changes bhv
reactimate' $ fmap (fmap (\v -> set obj [attr := v])) fe
This will update the attribute whenever an event occurs which feeds in to the behavior. Note that this will still happen even if the new value is the same as the old; unlike GTK the Reactive Banana library doesn't cancel updates if the new and old values are the same.
Plumbing
The Basic Concepts
Reactive Banana events and behaviors are connected together in the MomentIO monad. This is an instance of MonadFix so you can use recursive do notation, letting you create feedback loops between behaviors and events. MomentIO is also an instance of MonadIO, so you can use liftIO to bring GTK widget actions into it.
To set up a dialog containing a bunch of GTK widgets you do the following things in the MomentIO monad:
Use liftIO
on GTK functions to set up the widgets and
arrange them in layout boxes in the same way you would if you were
just using bare GTK.
Use registerIOSignal
to get Reactive Banana events from the widgets.
Use the Reactive Banana
combinators to create new events and behaviors reflecting the
application logic you want.
Use eventLink
and behaviorLink
to update widget attributes.
For instance you can have a pop-up dialog containing a bunch of input widgets with events attached to their values. Lets say these fields are arguments to the "FooData" constructor, and you also have a function "fooValid :: FooData -> Bool". You can then write your code like this:
fooDialog :: FooData -> MomentIO (Widget, Event FooData)
fooDialog (FooData v1 v2)= do
-- Create GTK widgets w1, w2 and okButton.
-- Put them all in a top-level "dialog" container.
.... your GTK code here.
-- Connect events ev1, ev2 to the values of w1 and w2.
.... your calls to registerIOSignal here.
okClick <- registerIOSignal okButton buttonActivate $ return ((), ())
bhv1 <- stepper v1 ev1
bhv2 <- stepper v2 ev2
let
fooB = FooData <$> bhv1 <*> bhv2
-- Behavior is an Applicative, so fooB :: Behavior FooData
validInput = fooValid <$> fooB
result = const <$> fooB <@> okClick
behaviorLink okButton widgetSensitive validInput
return (dialog, result)
The last line but one links the "validInput" behavior to the OK button sensitivity. So if the input data is not valid then the OK button is greyed out and will not respond to clicks. You can use the same technique do other more informative things like highlighting the offending widget or displaying a helpful message in another widget.
The "result =" line needs a bit of explanation. The "<@>" combinator in Reactive Banana works like the applicative "<*>" except that its second argument is an event rather than a behavior. The result is an event that combines the current value of the "fooB" behavior with the value from the "okClick" event. In this case the button click carries no information, so we use the function "const" to just take the current behavior value.
One tip: when writing pure functions that are going to be called using the "<@>" combinator its a good idea to put the argument that will come from the event last.
Keeping it Modular
I have found that these are good rules for designing applications in Reactive Banana:
Functions in the MomentIO monad
should take events and behaviors as parameters, but only return
events.
Avoid returning a behavior
unless you are sure that this is the only function that needs to
change it.
Keep the behavior definitions at the top of the call stack
where they are used. If in doubt, defer the job of defining the
behavior to your caller.
This lets you write independent modules that all have a say in updates to some shared value. The shared value should be represented as a Behavior, and updates to it as Events which either carry new values or (better) update functions. So you have a bunch of independent editor functions and a top level function which looks like this:
editor1, editor2, editor3 ::
Behavior FooData -> MomentIO (Event (FooData -> FooData))
fooDataManager :: FooData -> MomentIO ()
fooDataManager start = mdo -- Recursive do notation.
edit1 <- editor1 fooB
edit2 <- editor2 fooB
edit3 <- editor3 fooB
fooB <- accumB start $ unions [edit1, edit2, edit3]
-- accumB applies each event function to the accumulated behaviour value.