7GUIs defines seven tasks that represent typical challenges in GUI programming -- 7GUIs: A GUI Programming Benchmark
7GUIs is a set of 7 typical GUI programming tasks of varying levels of complexity. We're going to implement the 7GUIs in Haskell using the reflex functional reactive programming framework.
This tutorial is packaged, like most Haskell applications and libraries, as a cabal package. It was created by running cabal init
. One thing that is a little unusual, though not unheard of, is that the source file for this package is a literate Haskell file. Everything outside of code blocks that begin with "```haskell" will be ignored by the compiler.
We use the markdown-unlit preprocessor to allow us to write the tutorial in markdown (rather than one of Haskell's built-in literate document styles) but still allow the code blocks to be recognized and compiled.
Each of the code snippets will be a function that can be run on its own in the REPL. The functions will gradually grow in complexity until the final version implements the desired GUI. This isn't how you'd normally program, but we're trying to "show our work" so that you can see how we build up the GUI in smaller steps.
NB: One limitation of the format we've chosen (a single literate haskell source file) is that the module imports have to be declared in one place, above all of the code, so the first code you see will include imports that aren't needed until later. We'll use qualified imports to try to make it clear why something is being imported.
This the second blog post in a series of seven, about implementing 7GUIs: A GUI Programming Benchmark. In this series, we explore various data flow patterns, and how to approach certain programming challenges with reflex-vty
.
This tutorial is packaged, like most Haskell applications and libraries, as a cabal package.
For more details about the structure of this document, we refer to the first blog post.
We use reflex-platform to build an environment that contains all the dependencies you need to build this project. You can either compile this project and run the resulting executable, or open it in an interpreter and run it from there. The latter makes for a quicker development cycle if you're modifying any of the example code.
To enter a shell from which you can build the project or enter the REPL, run nix-shell
from the project directory.
You'll be put in a nix-shell that looks something like this:
[nix-shell:/path/to/project]$
This tells you that you're in a "nix-shell" environment. Inside this environment, the dependencies for our project are ready for us to use.
From within the nix-shell, you can run:
cabal repl seven-guis-vty-temperature-converter
to enter a REPLcabal build seven-guis-vty-temperature-converter
to build the projectghcid -c "cabal repl seven-guis-vty-temperature-converter"
to watch source files and display any errors or warnings that arise when they change
From within the REPL, you can run any of the functions defined later in this tutorial (e.g., hello
) to see what it does.
To compile the project via nix, run nix-build -A seven-guis-vty-temperature-converter
from the project directory. This will create a result
symlink in your project folder. Run the program by executing result/bin/seven-guis-vty-temperature-converter
.
Enough with the preliminaries; let's get on to the code.
We will jump right into it! This tutorial assumes you have read the first blog post in this series; as such, we won't explain functions that have been encountered before again. First, let's get started with the preliminaries.
As mentioned above, we'll start by importing the libraries our project depends on. Don't worry if you don't understand why something is in the import list: we'll explain that as we go.
-- We'll use ScopedTypeVariables to be able to annotate some types
{-# LANGUAGE ScopedTypeVariables #-}
-- These imports are unqualified because we'll be using them heavily
import Reflex
import Reflex.Vty
import qualified Graphics.Vty as V
import qualified Data.Text as T
import qualified Data.Text.Zipper as Z
import qualified Text.Read as Read
We start with the code from our previous blog post. As a reminder, we ended up with something like this:
counter :: IO ()
counter = mainWidget $ initManager_ $ do
getout <- ctrlc
tile flex $ box (pure roundedBoxStyle) $ row $ do
rec grout flex $ text numClicksText
buttonClicked <- tile flex $ textButton def "Count"
(numClicks :: Dynamic t Integer) <- count buttonClicked
let numClicksText = current $ fmap (T.pack . show) numClicks
return ()
return $ fmap (\_ -> ()) getout
ctrlc :: (Monad m, Reflex t, HasInput t m) => m (Event t KeyCombo)
ctrlc = keyCombo (V.KChar 'c', [V.MCtrl])
When we run this application, we see a nice little GUI:
Now, for our next challenge, we want to have a temperature converter! Given some input interpreted in degrees Celsius, it will produce an output in Fahrenheit. This on its own is not that complicated, but we want to have a two-way update cycle. That is, we may enter some temperature in Celsius, but we may also do it the other way round, inputting degrees Fahrenheit and having the application calculate that amount in Celsius degrees.
The exemplar for this GUI challenge looks like this:
As we can see, our GUI consists of similar parts as before: we have a frame, multiple textfields, and some labels. We will start from our previous program to avoid retyping all this plumbing, making small adaptations to it until we reach the program we desire.
The first thing we will need is a way to enter some input. The right function for this job is textInput
with the following signature:
textInput :: (Reflex t, MonadHold t m, MonadFix m, HasInput t m, HasFocusReader t m, HasTheme t m, HasDisplayRegion t m, HasImageWriter t m, HasDisplayRegion t m) => TextInputConfig t -> m (TextInput t)
Just like last time, this is a lot to look at. However, by now you've most likely realized that you can ignore almost everything in front of the fat arrow =>
for the sake of simplicity, since an intuitive understanding of the signature suffices. The only thing we truly care about is the part after =>
, namely:
TextInputConfig t -> m (TextInput t)
This essentially tells us that we can configure our textInput
in some way, and then we get a TextInput
, that we most likely can use to get the contents of the textInput
.
NB: We use
textInput
to refer to the text input that can be seen on the screen, andTextInput
to refer to the data type that is returned bytextInput
.
Let's start using a textInput
! We can use it just like we did textButton
for the first GUI challenge:
singleTextInput :: IO ()
singleTextInput = mainWidget $ initManager_ $ do
getout <- ctrlc
tile flex $ box (pure roundedBoxStyle) $ row $ do
rec _ <- tile flex $ textInput def
return ()
return $ fmap (\_ -> ()) getout
We basically deleted everything that takes care of the counter logic and replaced that with a single tile flex $ textInput def
.
Running this renders roughly as follows:
After you click on the text input, you can freely enter anything you'd like.
Just as with textButton
, the function def
creates a default value for the type TextInputConfig
. We will take a much closer look at it later, but let's finish the layout first.
This time, we don't need a very complex layout: just two text inputs and two labels in a single row.
multipleTextInputLayout :: IO ()
multipleTextInputLayout = mainWidget $ initManager_ $ do
getout <- ctrlc
tile flex $ box (pure roundedBoxStyle) $ row $ do
rec _ <- tile flex $ textInput def
grout flex $ text "Celsius ="
_ <- tile flex $ textInput def
grout flex $ text "Fahrenheit"
return ()
return $ fmap (\_ -> ()) getout
As an exercise, fool around with the layout! Make it column based, or add a white box around the textInput to make it easier to see.
In both text inputs, we can enter text, but neither of the text inputs reacts to the contents of the other. That's not what we want, so let's change that!
As a first step, we want to be able to enter a number in the "Celsius" input field, and have it update the contents of the "Fahrenheit" input field whenever the input of the former is a number. If it isn't, we won't update the input field. Ah, well, but how are we supposed to do this? We will have to take a look at TextInput
and TextInputConfig
. Here are the fields that are relevant to us:
data TextInputConfig t = TextInputConfig
{ _textInputConfig_initialValue :: Z.TextZipper
, _textInputConfig_modify :: Event t (Z.TextZipper -> Z.TextZipper)
...
}
NB: we removed documentation for the fields, as it mentions things we are not ready to talk about yet; however, do notice that the documentation covers what we talk about here.
The two fields from TextInputConfig
we care about for this challenge are _textInputConfig_initialValue
and _textInputConfig_modify
. Both of these fields work somehow with Z.TextZipper
, but we don't know yet what Z.TextZipper
is, so we have to make a very quick detour.
Z.TextZipper
is a slightly more complicated data structure than plain Text
, because it saves more information. For example, the Z.TextZipper
allows us to choose where the cursor currently is in the text input. For the sake of simplicity, you can assume that Z.TextZipper
is basically a string with extra information that is necessary for more complex user interaction, but it is also kind of an implementation detail.
Ok, now that we have established Z.TextZipper
is basically Text
, or a string, we can take a look at the fields we care about again.
data TextInputConfig t = TextInputConfig
{ _textInputConfig_initialValue :: Z.TextZipper
, _textInputConfig_modify :: Event t (Z.TextZipper -> Z.TextZipper)
...
}
The expected value of _textInputConfig_initialValue
becomes now apparent: it is just some string that we want to display at the very beginning. Thanks to OverloadedStrings
, we may even just write a string directly.
On the other hand _textInputConfig_modify
looks a bit more complicated than it is. It basically just says: if an event is supplied, then apply the function in that event to the value in the textInput
. You get the old value, or the current value, of the Z.TextZipper
that's currently in the textInput
, as a parameter, and may decide based on the old value how to modify the Z.TextZipper
. We, probably, want to discard the old value,
and just produce a new value, but we will get to it.
In any way, using _textInputConfig_modify
seems straightforward enough and sounds like exactly the thing we are looking for! We just need to know when our text input changes. Maybe TextInput
will shed more light on how:
data TextInput t = TextInput
{ _textInput_value :: Dynamic t Text
, _textInput_userInput :: Event t Z.TextZipper
...
}
Aha! If you remember, a Dynamic
is basically a Behavior
and Event
at the same time, and it even contains exactly what is written in our text input! Sounds like TextInput is exactly what need. Let's give it a try and hook up the _textInput_value
from our Celsius input field with the _textInputConfig_modify
of our Fahrenheit input field.
wrongSynchronizeCelsiusAndFahrenheit :: IO ()
wrongSynchronizeCelsiusAndFahrenheit = mainWidget $ initManager_ $ do
getout <- ctrlc
tile flex $ box (pure roundedBoxStyle) $ row $ do
rec celsiusInput <- tile flex $ textInput def
grout flex $ text "Celsius ="
let celsiusEv = updated (_textInput_value celsiusInput)
let fahrenheitConfig = def
{ _textInputConfig_modify =
fmap (\tz -> const (Z.fromText tz)) celsiusEv
}
_ <- tile flex $ textInput fahrenheitConfig
grout flex $ text "Fahrenheit"
return ()
return $ fmap (\_ -> ()) getout
NB:
Z.fromText
converts aText
value toZ.TextZipper
.
And it seems to be doing what we want:
However, this is unfortunately not entirely correct! In fact, we will run into problems as soon as we try to hook up the Fahrenheit text input back into _textInputConfig_modify
for Celsius. The issue is quite fundamental, since Dynamic t Text
changes when either the user inputs something in the UI, or when _textInputConfig_modify
is used to update the value.
Let's walk through an example:
- You input "42" into the celsius field.
- The value of
_textInput_value celsiusInput
changes. - The event from
updated (_textInput_value celsiusInput)
is triggered. - This causes the Event
_textInputConfig_modify
to fire, changing the value of_textInput_value fahrenheitInput
. - Since the value of
_textInput_value fahrenheitInput
changes, the eventupdated (_textInput_value fahrenheitInput)
fires. - Now, we set the value of
_textInput_value celsiusInput
again, and jump back to 3.
So, we have infinite update loop: no good!
Try it out. Wire up the two textInputs as described and watch the UI hang immediately, not even rendering anything!
Let's take a look at a diagram to visualize the issue:
In short, our current design can not work. What we need instead is something that resembles this:
While these might almost look the same, the important bit is to separate the User Input
and modifyValue
events. Previously, we subscribed to the change events of our value in the text input. What we need is to subscribe to the events the user creates. And this is where the function _textInput_userInput
comes into play. It only contains the events created by the user: the contents of our text input after the User Input
events (e.g. keyboard presses, etc) have been applied to our text input.
Now that we know how to break up the cyclic update dependency, let's take a look at how we can keep the contents of our two text inputs truly synchronized:
synchronizeTextInputs :: IO ()
synchronizeTextInputs = mainWidget $ initManager_ $ do
getout <- ctrlc
tile flex $ box (pure roundedBoxStyle) $ row $ do
rec let celsiusConfig = def
{ _textInputConfig_modify =
fmap const (_textInput_userInput fahrenheitInput)
}
celsiusInput <- tile flex $ textInput celsiusConfig
grout flex $ text "Celsius ="
let fahrenheitConfig = def
{ _textInputConfig_modify =
fmap const (_textInput_userInput celsiusInput)
}
fahrenheitInput <- tile flex $ textInput fahrenheitConfig
grout flex $ text "Fahrenheit"
return ()
return $ fmap (\_ -> ()) getout
You should play around with this in your UI to make sure it actually does what we promise you.
Now we that we have a bidirectional data flow setup, we can start implementing the actual feature that we want.
We start by introducing some helper functions:
-- | Convert temperature in degree Fahrenheit to degree Celsius.
toC :: Double -> Double
toC f = (f - 32) * (5 / 9)
-- | Convert temperature in degree Celsius to degree Fahrenheit.
toF :: Double -> Double
toF c = c * (9 / 5) + 32
-- | Try to interpret the given 'TextZipper' as a number.
toNumber :: Z.TextZipper -> Maybe Double
toNumber tz = Read.readMaybe (T.unpack (Z.value tz))
-- | Put the given number into a `TextZipper`.
toTZ :: Double -> Z.TextZipper
toTZ val = Z.fromText (T.pack (show val))
Functions such as T.pack
and T.unpack
are necessary for converting from strings to Text and vice versa. The most interesting function is readMaybe :: Read a => String -> Maybe a
which tries to read a string and translates it into a value of our choice. In this particular case, we want it to be a Double
, hence toNumber
produces a Maybe Double
.
These functions are not particularly interesting, but they make the following implementation much simpler.
First, we want the Fahrenheit input to always display the input from the Celsius text field:
celsiusToFahrenheit :: IO ()
celsiusToFahrenheit = mainWidget $ initManager_ $ do
getout <- ctrlc
tile flex $ box (pure roundedBoxStyle) $ row $ do
rec celsiusInput <- tile flex $ textInput def
grout flex $ text "Celsius ="
_fahrenheitInput <- tile flex $ textInput def
{ _textInputConfig_modify = fmap const setFahrenheitEvent
}
grout flex $ text "Fahrenheit"
let setFahrenheitEvent =
fforMaybe (_textInput_userInput celsiusInput) $ \val ->
case toNumber val of
Nothing -> Nothing
Just num -> Just (toTZ (toF num))
return ()
return $ fmap (\_ -> ()) getout
For this step, we remove the bidirectional data-flow to make it easier to understand.
Our program looks like this:
Now to the actual code. Most of it should feel familiar, except for fforMaybe
. Let's take a closer look.
fforMaybe :: Filterable f => f a -> (a -> Maybe b) -> f b
The function fforMaybe
allows us to map over something, but also, at the same time, to discard something. It is basically a Functor
, but you can also decide to discard values from it. In the case of Event
, this means that we can take the Event
and decide, based on the contents of the Event
, to not trigger the Event
anymore.
Why do we need this in this case? Well, parsing a string to a number may fail since "not a number"
would not be a valid number. Of course, we could return some kind of default value in that case, but our assignment says specifically:
When the user enters a non-numerical string into
$T_C$ the value in$T_F$ is not updated and vice versa.
where
Thus, using fforMaybe
, if we return a Nothing
, we will not update the text field of our Fahrenheit text input. Hooray, we are almost there!
Ok, we've learned a lot. We've learned how to model complex data flow patterns, but at the same time, we've also taken a close look at how not to model complex data flow patterns. Hopefully this has been instructive for you.
Now that we know how to model the bidirectional data flow pattern between two text inputs, and we know how we have to modify the events to apply the temperature conversion correctly, we come to the grand finale. It is just a matter of duplicating the work we have done to convert Celsius to Fahrenheit.
bidirectionalTemperature :: IO ()
bidirectionalTemperature = mainWidget $ initManager_ $ do
getout <- ctrlc
tile flex $ box (pure roundedBoxStyle) $ row $ do
rec celsiusInput <- tile flex $ textInput def
{ _textInputConfig_modify = fmap const setCelsiusEvent
}
grout flex $ text "Celsius ="
fahrenheitInput <- tile flex $ textInput def
{ _textInputConfig_modify = fmap const setFahrenheitEvent
}
grout flex $ text "Fahrenheit"
let celsiusEv = _textInput_userInput celsiusInput
fahrenheitEv = _textInput_userInput fahrenheitInput
setCelsiusEvent =
fforMaybe fahrenheitEv $ \val ->
case toNumber val of
Nothing -> Nothing
Just num -> Just (toTZ (toC num))
setFahrenheitEvent =
fforMaybe celsiusEv $ \val ->
case toNumber val of
Nothing -> Nothing
Just num -> Just (toTZ (toF num))
return ()
return $ fmap (\_ -> ()) getout
This is more of the same as before. We wire up the user input events from the two text inputs, and only if the input is a number do we update the shown text in the text field. We have achieved our goal. The only thing left to do is to watch in amazement.
Let's plug that into main
so that the compiled executable runs our final version.
main :: IO ()
main = bidirectionalTemperature
Be not mistaken, our program is done, but we can still improve it a bit by removing the code duplication that is setCelsiusEvent
and setFahrenheitEvent
. The only differences between them are which Event
we are handling and how to convert the parsed value into Fahrenheit or Celsius respectively. Thus, we pull it out like this:
convertEvent :: Reflex t => (Double -> Double) -> Event t Z.TextZipper -> Event t Z.TextZipper
convertEvent conv ev =
fforMaybe ev $ \val ->
case toNumber val of
Nothing -> Nothing
Just num -> Just (toTZ (conv num))
and then we may refactor our code to use that utility function:
thisIsIt :: IO ()
thisIsIt = mainWidget $ initManager_ $ do
getout <- ctrlc
tile flex $ box (pure roundedBoxStyle) $ row $ do
rec
let setCelsiusEvent = convertEvent toC (_textInput_userInput fahrenheitInput)
let setFahrenheitEvent = convertEvent toF (_textInput_userInput celsiusInput)
celsiusInput <- tile flex $ textInput def
{ _textInputConfig_modify = fmap const setCelsiusEvent
}
grout flex $ text "Celsius ="
fahrenheitInput <- tile flex $ textInput def
{ _textInputConfig_modify = fmap const setFahrenheitEvent
}
grout flex $ text "Fahrenheit"
return ()
return $ fmap (\_ -> ()) getout
With this refactoring, our code is concise, but still readable, once you understand how the "wiring" works with reflex
.
Make sure to run our real final version.
main :: IO ()
main = thisIsIt
Thanks for reading. Tune in next time for a walkthrough of the flight booker task.