data Digit
= Seven | Eight | Nine
| Four | Five | Six
| One | Two | Three
| Zero deriving (Eq, Show, Ord, Enum, Bounded)
Immediate Execution
All the prior articles on writing a calculator in GHCjs feature old school functionality, namely immediate execution, which is a terrible UX. If you ever make an actual calculator app, at least let your users type into an input the expression they want evaluated. However, this choice makes good sense, as it requires a state machine and some other properties that make it good for learning.
This portion of the tutorial makes use of Lens in an effort to show how you might leverage optics in your applications.
Digits
Let’s model out our calculator as types. We could easily just use Char
or Int
to represent our numbers, however this violates the principle of making illegal states unrepresentable. So instead we will use an ADT and marshal.
Next, before we can make buttons for the digits, we need a way marshal a Digit
to its respective Char
, which can be handled nicely by a Prism. We need it to be a Prism because not all Char
s have a corresponding Digit
.
charDigit :: Prism' Char Digit
Now that we can marshal between the Haskell representation and one we can show our user, we can make a button for digits:
digit :: Digit -> Html m Digit
digit d = button [ onClick $ const d (1)
, className $ "d" <> d' (2)
] [ text d' ]
where d' = d ^. re charDigit . to (pack . pure)
1 | When clicking the button, we send the corresponding digit |
2 | Add a class for styling later |
Entries
Digit
gives us everything we need to represent our number pad, but it is not enough to represent a user inputed number. So we can make a simple type to represent an entry consisting of multiple digits.
newtype Entry = Entry [Digit]
This works so long as our calculator need only work with Natural numbers, however we want to do a bit more. We want to allow for decimals as well as negative numbers.
Decimals
To allow for decimals, we need to have a distinction so when the user presses a digit on the number pad, we know whether to add it before or after the decimal. We also need to ensure that when the user presses btn:[.] we can move from one state to the other.
data Entry = Whole [Digit] | [Digit] :<.> [Digit]
With this we can implement the decimal interaction logic easily. When the user presses btn:[.] and we have a whole number, we transition to the :<.>
constructor.
addDecimal :: Entry -> Entry
addDecimal = \case
Whole xs -> xs :<.> []
ys -> ys
When the user presses a digit button and we have a whole number, we append the new digit to the end of the list. Otherwise, we append to the space after the decimal.
applyDigit :: Digit -> Entry -> Entry
applyDigit d = \case
Whole xs -> Whole $ xs <> [d]
xs :<.> ys -> xs :<.> (ys <> [d])
Negative
To allow for negatives, we can just make the Entry
type recursive.
data Entry = Whole [Digit]
| [Digit] :<.> [Digit]
| Negate Entry
By adding the Negate
constructor we can represent negatives of any existing Entry
in the system. This makes the logic for the btn:[-/+] button straight forward.
neg :: Entry -> Entry
neg = \case
Negate e -> e
e -> Negate e
Show
Now to show the entry to our user in the calculator readout, we need to convert to a human readable version.
instance Show Entry where
show = let asChar = traverse . re charDigit in \case
Whole xs -> xs ^.. asChar
xs :<.> ys -> xs ^.. asChar <> "." <> ys ^.. asChar
Negate e -> '-' : show e
This shows instance results in an intuitive display for the user where each button press is reflected 1:1. (IE no trailing ".0" or other issues)
Double
Lastly on the subject of entries, we need the ability to do math. To accomplish this we need to marshal entries to an actual number type (in this case Double
), as well as marshal our results back to an Entry
. We do this by creating an Iso.
frac :: Iso' Entry Double
This allows us to easily move in both directions.
Outlaw
The source version is not a lawful isomorphism, but this outlaw is harmless. Producing a lawful instance is left as an exercise for the reader.
|
Operators
We need a representation of operators to allow our user to perform work. We can do this with another simple enumeration:
data Operator
= Addition
| Multiplication
| Subtraction
| Division
deriving (Eq, Enum, Bounded)
And as before we need to show this to our user:
instance Show Operator where
show = \case
Addition -> "+"
Subtraction -> "−"
Multiplication -> "×"
Division -> "÷"
operate :: Maybe Operator -> Operator -> Html m Operator
operate active o = button
[ onClick (const o) (1)
, className ("active", Just o == active) (2)
] [ text . pack $ show o ]
1 | When clicked, the button sends the corresponding operator |
2 | Set the "active" class if this button is the active button (for styling) |
Model
Now we can actually define our model. Ultimately, the immediate execution calculator is a state machine with two major states:
-
There is a current entry.
-
There is a current entry, and a previous entry, and an operation.
Input | Readout | Current | Operation | Entry | |
---|---|---|---|---|---|
|
|||||
|
|
|
|||
|
|
|
|||
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
One way to model this is with the following type:
data Operation = Operation
{ _operator :: Operator
, _previous :: Entry
} deriving (Eq, Show)
makeFieldsNoPrefix ''Operation
data Model = Model
{ _current :: Entry (1)
, _operation :: Maybe Operation (2)
} deriving (Eq, Show)
makeFieldsNoPrefix ''Model
1 | We always have a current entry. |
2 | We might have a previous entry and an operation. |
Buttons
Now let’s start building the final view. The calculator needs a readout area that shows the user the current state of the system.
readout :: Model -> Html m a
Because the readout consumes the state but never produces an update we should leave the HTML parametric.
The presence a in the above signature is proof that the HTML produced is non-interactive.
|
We also need our buttons. This calculator will have the following familiar buttons:
All Clear
Resets the calculator to the initial
state.
clear :: Html m Model
clear = button [ class' "clear", onClick $ const initial ] [ "AC" ]
Negate
Negates the current entry. Phrased on the button as [-/+].
posNeg :: Html m Model
posNeg = button [ class' "posNeg", onClick (current %~ neg) ] [ "-/+" ]
Numberpad
The nine digit pad (excluding 0).
numberpad :: Html m Digit
numberpad = H.div "numberpad"
. L.intercalate [ br'_ ] (3)
. L.chunksOf 3 (2)
$ digit <$> [minBound .. pred maxBound] (1)
1 | Get a list all members of our Digit type, excluding Zero . We are leveraging the derived Ord instance here, as the type definition already has the digits arranged for the number pad, with Zero as maxBound . |
2 | Split the resulting list of HTML into rows of three buttons each. (chunksOf is a part of Data.List ) |
3 | Add <br/> between each row. |
Decimal
A button to apply adding a decimal point to the current entry.
dot :: Html m Model
dot = button [ onClick $ current %~ addDecimal ] [ "." ]
Arithmetic
Each operator button does the following: . Sets the operation to the given operator. . Sets the previous entry to be the current entry. . Blanks the current entry.
operations :: Model -> Html m Model
operations x = H.div "operate" $ fmap (\o -> x
& operation .~ Just (Operation o (x ^. current))
& current .~ noEntry) (3)
. operate (x ^? operation . traverse . operator) (2)
<$> [minBound .. maxBound] (1)
1 | Leverage Bounded and Enum to get a list of operators. |
2 | Get the current operator if there is one (for display purposes). |
3 | Apply the update described above. |
Equals
Last we come to equals. This button should calculate the result of our operation, blank the operator and previous entry, and set the current entry to our result.
calcResult :: Model -> Model
calcResult x = x
& operation .~ Nothing
& current .~ case x ^. operation of
Nothing -> x ^. current
Just o ->
let l = o ^. previous . frac
r = x ^. current . frac
in (^. from frac) $ case o ^. operator of
Addition -> l + r
Subtraction -> l - r
Multiplication -> l * r
Division -> if r == 0 then l else l / r
Based on the above examples, you should be able to see what is going on in this code. Writing a button to perform this operation is straightforward.
equals :: Html m Model
equals = button [ class' "equals", onClick calcResult ] [ "=" ]
The View
Now, we can construct the final view by composing together our existing parts.
view :: Model -> Html Model
view x = H.div "calculator"
[ readout x
, H.div "buttons"
[ clear, posNeg, operations x
, numberpad
, H.div "zerodot"
[ digit Zero, dot, equals ]
]
]
And we are done.
Conclusion
Simplicity
There were no Monads, no message types, no FRP networking, no causality, and we never considered when or how components render. Instead we focused on data structures, and simple functions with simple types.
|
Thank you for your time.