mirror of
https://github.com/jtdaugherty/brick.git
synced 2024-09-11 15:55:54 +03:00
Add Samuel Tay's brick tutorial (with permission)
This commit is contained in:
parent
603cdcae41
commit
7a1950daa5
541
docs/samtay-tutorial.md
Normal file
541
docs/samtay-tutorial.md
Normal file
@ -0,0 +1,541 @@
|
||||
|
||||
# Brick Tutorial by Samuel Tay
|
||||
|
||||
This tutorial was written by Samuel Tay, Copyright 2017
|
||||
(https://github.com/samtay, https://samtay.github.io/). It is provided
|
||||
as part of the brick distribution with permission.
|
||||
|
||||
## Introduction
|
||||
|
||||
I'm going to give a short introduction to
|
||||
[brick](https://hackage.haskell.org/package/brick), a Haskell library
|
||||
for building terminal user interfaces. So far I've used `brick` to
|
||||
implement [Conway's Game of Life](https://github.com/samtay/conway) and
|
||||
a [Tetris clone](https://github.com/samtay/tetris). I'll explain the
|
||||
basics, walk through an example [snake](https://github.com/samtay/snake)
|
||||
application, and then explain some more complicated scenarios.
|
||||
|
||||
The first thing I'll say is that this package has some of the most
|
||||
impressive documentation and resources, which makes it easy to figure
|
||||
out pretty much anything you need to do. I'll try to make this useful,
|
||||
but I imagine if you're reading this then it is mostly being used as a
|
||||
reference in addition to the existing resources:
|
||||
|
||||
1. [Demo programs](https://github.com/jtdaugherty/brick/tree/master/programs)
|
||||
(clone down to explore the code and run them locally)
|
||||
2. [User guide](https://github.com/jtdaugherty/brick/blob/master/docs/guide.rst)
|
||||
3. [Haddock docs](https://hackage.haskell.org/package/brick-0.18)
|
||||
4. [Google group](https://groups.google.com/forum/#!forum/brick-users)
|
||||
|
||||
### The basic idea
|
||||
|
||||
`brick` is very declarative. Once your base application logic is in
|
||||
place, the interface is generally built by two functions: drawing and
|
||||
handling events. The drawing function
|
||||
|
||||
```haskell
|
||||
appDraw :: s -> [Widget n]
|
||||
```
|
||||
|
||||
takes your app state `s` and produces the visuals `[Widget n]`. The
|
||||
handler
|
||||
|
||||
```haskell
|
||||
appHandleEvent :: s -> BrickEvent n e -> EventM n (Next s)
|
||||
```
|
||||
|
||||
takes your app state, an event (e.g. user presses the `'m'` key), and
|
||||
produces the resulting app state. *That's pretty much it.*
|
||||
|
||||
## `snake`
|
||||
|
||||
We're going to build the [classic
|
||||
snake](https://en.wikipedia.org/wiki/Snake_(video_game)) game that you
|
||||
might recall from arcades or the first cell phones. The full source code
|
||||
is [here](https://github.com/samtay/snake). This is the end product:
|
||||
|
||||
![](docs/snake-demo.gif)
|
||||
|
||||
### Structure of the app
|
||||
|
||||
The library makes it easy to separate the concerns of your application
|
||||
and the interface; I like to have a module with all of the core business
|
||||
logic that exports the core state of the app and functions for modifying
|
||||
it, and then have an interface module that just handles the setup,
|
||||
drawing, and handling events. So let's just use the `simple` stack
|
||||
template and add two modules
|
||||
|
||||
```
|
||||
├── LICENSE
|
||||
├── README.md
|
||||
├── Setup.hs
|
||||
├── snake.cabal
|
||||
├── src
|
||||
│ ├── Main.hs
|
||||
│ ├── Snake.hs
|
||||
│ └── UI.hs
|
||||
└── stack.yaml
|
||||
```
|
||||
|
||||
and our dependencies to `test.cabal`
|
||||
|
||||
```yaml
|
||||
executable snake
|
||||
hs-source-dirs: src
|
||||
main-is: Main.hs
|
||||
exposed-modules: Snake
|
||||
, UI
|
||||
default-language: Haskell2010
|
||||
build-depends: base >= 4.7 && < 5
|
||||
, brick
|
||||
, containers
|
||||
, linear
|
||||
, microlens
|
||||
, microlens-th
|
||||
, random
|
||||
```
|
||||
|
||||
### `Snake`
|
||||
|
||||
Since this tutorial is about `brick`, I'll elide most of the
|
||||
implementation details of the actual game, but here are some of the key
|
||||
types and scaffolding:
|
||||
|
||||
```haskell
|
||||
{-# LANGUAGE TemplateHaskell, FlexibleContexts #-}
|
||||
module Snake where
|
||||
|
||||
import Control.Applicative ((<|>))
|
||||
import Control.Monad (guard)
|
||||
import Data.Maybe (fromMaybe)
|
||||
|
||||
import Data.Sequence (Seq, ViewL(..), ViewR(..), (<|))
|
||||
import qualified Data.Sequence as S
|
||||
import Lens.Micro.TH (makeLenses)
|
||||
import Lens.Micro ((&), (.~), (%~), (^.))
|
||||
import Linear.V2 (V2(..), _x, _y)
|
||||
import System.Random (Random(..), newStdGen)
|
||||
|
||||
-- Types
|
||||
|
||||
data Game = Game
|
||||
{ _snake :: Snake -- ^ snake as a sequence of points in R2
|
||||
, _dir :: Direction -- ^ direction
|
||||
, _food :: Coord -- ^ location of the food
|
||||
, _foods :: Stream Coord -- ^ infinite list of random food locations
|
||||
, _dead :: Bool -- ^ game over flag
|
||||
, _paused :: Bool -- ^ paused flag
|
||||
, _score :: Int -- ^ score
|
||||
, _frozen :: Bool -- ^ freeze to disallow duplicate turns
|
||||
} deriving (Show)
|
||||
|
||||
type Coord = V2 Int
|
||||
type Snake = Seq Coord
|
||||
|
||||
data Stream a = a :| Stream a
|
||||
deriving (Show)
|
||||
|
||||
data Direction
|
||||
= North
|
||||
| South
|
||||
| East
|
||||
| West
|
||||
deriving (Eq, Show)
|
||||
```
|
||||
|
||||
All of this is pretty self-explanatory, with the possible exception
|
||||
of lenses if you haven't seen them. At first glance they may seem
|
||||
complicated (and the underlying theory arguably is), but using them as
|
||||
getters and setters is very straightforward. So, if you are following
|
||||
along because you are writing a terminal app like this, I'd recommend
|
||||
using them, but they are not required to use `brick`.
|
||||
|
||||
Here are the core functions for playing the game:
|
||||
|
||||
```haskell
|
||||
-- | Step forward in time
|
||||
step :: Game -> Game
|
||||
step g = fromMaybe g $ do
|
||||
guard (not $ g ^. paused || g ^. dead)
|
||||
let g' = g & frozen .~ False
|
||||
return . fromMaybe (move g') $ die g' <|> eatFood g'
|
||||
|
||||
-- | Possibly die if next head position is disallowed
|
||||
die :: Game -> Maybe Game
|
||||
|
||||
-- | Possibly eat food if next head position is food
|
||||
eatFood :: Game -> Maybe Game
|
||||
|
||||
-- | Move snake along in a marquee fashion
|
||||
move :: Game -> Game
|
||||
|
||||
-- | Turn game direction (only turns orthogonally)
|
||||
--
|
||||
-- Implicitly unpauses yet freezes game
|
||||
turn :: Direction -> Game -> Game
|
||||
|
||||
-- | Initialize a paused game with random food location
|
||||
initGame :: IO Game
|
||||
```
|
||||
|
||||
### `UI`
|
||||
|
||||
To start, we need to determine what our `App s e n` type parameters are.
|
||||
This will completely describe the interface application and be passed
|
||||
to one of the library's `main` style functions for execution. Note that
|
||||
`s` is the app state, `e` is an event type, and `n` is a resource name.
|
||||
The `e` is abstracted so that we can provide custom events. The `n`
|
||||
is usually a custom sum type called `Name` which allows us to *name*
|
||||
particular viewports. This is important so that we can keep track of
|
||||
where the user currently has *focus*, such as typing in one of two
|
||||
textboxes; however, for this simple snake game we don't need to worry
|
||||
about that.
|
||||
|
||||
In simpler cases, the state `s` can directly coincide with a core
|
||||
datatype such as our `Snake.Game`. In many cases however, it will be
|
||||
necessary to wrap the core state within the ui state `s` to keep track
|
||||
of things that are interface specific (more on this later).
|
||||
|
||||
Let's write out our app definition and leave some undefined functions:
|
||||
|
||||
```haskell
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
module UI where
|
||||
|
||||
import Control.Monad (forever, void)
|
||||
import Control.Monad.IO.Class (liftIO)
|
||||
import Control.Concurrent (threadDelay, forkIO)
|
||||
import Data.Maybe (fromMaybe)
|
||||
|
||||
import Snake
|
||||
|
||||
import Brick
|
||||
( App(..), AttrMap, BrickEvent(..), EventM, Next, Widget
|
||||
, customMain, neverShowCursor
|
||||
, continue, halt
|
||||
, hLimit, vLimit, vBox, hBox
|
||||
, padRight, padLeft, padTop, padAll, Padding(..)
|
||||
, withBorderStyle
|
||||
, str
|
||||
, attrMap, withAttr, emptyWidget, AttrName, on, fg
|
||||
, (<+>)
|
||||
)
|
||||
import Brick.BChan (newBChan, writeBChan)
|
||||
import qualified Brick.Widgets.Border as B
|
||||
import qualified Brick.Widgets.Border.Style as BS
|
||||
import qualified Brick.Widgets.Center as C
|
||||
import qualified Graphics.Vty as V
|
||||
import Data.Sequence (Seq)
|
||||
import qualified Data.Sequence as S
|
||||
import Linear.V2 (V2(..))
|
||||
import Lens.Micro ((^.))
|
||||
|
||||
-- Types
|
||||
|
||||
-- | Ticks mark passing of time
|
||||
--
|
||||
-- This is our custom event that will be constantly fed into the app.
|
||||
data Tick = Tick
|
||||
|
||||
-- | Named resources
|
||||
--
|
||||
-- Not currently used, but will be easier to refactor
|
||||
-- if we call this "Name" now.
|
||||
type Name = ()
|
||||
|
||||
data Cell = Snake | Food | Empty
|
||||
|
||||
-- App definition
|
||||
|
||||
app :: App Game Tick Name
|
||||
app = App { appDraw = drawUI
|
||||
, appChooseCursor = neverShowCursor
|
||||
, appHandleEvent = handleEvent
|
||||
, appStartEvent = return
|
||||
, appAttrMap = const theMap
|
||||
}
|
||||
|
||||
main :: IO ()
|
||||
main = undefined
|
||||
|
||||
-- Handling events
|
||||
|
||||
handleEvent :: Game -> BrickEvent Name Tick -> EventM Name (Next Game)
|
||||
handleEvent = undefined
|
||||
|
||||
-- Drawing
|
||||
|
||||
drawUI :: Game -> [Widget Name]
|
||||
drawUI = undefined
|
||||
|
||||
theMap :: AttrMap
|
||||
theMap = undefined
|
||||
```
|
||||
|
||||
#### Custom Events
|
||||
|
||||
So far I've only used `brick` to make games which need to be redrawn
|
||||
as time passes, with or without user input. This requires using
|
||||
`Brick.customMain` with that `Tick` event type, and opening a forked
|
||||
process to `forever` feed that event type into the channel. Since this
|
||||
is a common scenario, there is a `Brick.BChan` module that makes this
|
||||
pretty quick:
|
||||
|
||||
```haskell
|
||||
main :: IO ()
|
||||
main = do
|
||||
chan <- newBChan 10
|
||||
forkIO $ forever $ do
|
||||
writeBChan chan Tick
|
||||
threadDelay 100000 -- decides how fast your game moves
|
||||
g <- initGame
|
||||
void $ customMain (V.mkVty V.defaultConfig) (Just chan) app g
|
||||
```
|
||||
|
||||
We do need to import `Vty.Graphics` since `customMain` allows us
|
||||
to specify a custom `IO Vty.Graphics.Vty` handle, but we're only
|
||||
customizing the existence of the event channel `BChan Tick`. The app
|
||||
is now bootstrapped, and all we need to do is implement `handleEvent`,
|
||||
`drawUI`, and `theMap` (handles styling).
|
||||
|
||||
#### Handling events
|
||||
|
||||
Handling events is largely straightforward, and can be very clean when
|
||||
your underlying application logic is taken care of in a core module. All
|
||||
we do is essentially map events to the proper state modifiers.
|
||||
|
||||
```haskell
|
||||
handleEvent :: Game -> BrickEvent Name Tick -> EventM Name (Next Game)
|
||||
handleEvent g (AppEvent Tick) = continue $ step g
|
||||
handleEvent g (VtyEvent (V.EvKey V.KUp [])) = continue $ turn North g
|
||||
handleEvent g (VtyEvent (V.EvKey V.KDown [])) = continue $ turn South g
|
||||
handleEvent g (VtyEvent (V.EvKey V.KRight [])) = continue $ turn East g
|
||||
handleEvent g (VtyEvent (V.EvKey V.KLeft [])) = continue $ turn West g
|
||||
handleEvent g (VtyEvent (V.EvKey (V.KChar 'k') [])) = continue $ turn North g
|
||||
handleEvent g (VtyEvent (V.EvKey (V.KChar 'j') [])) = continue $ turn South g
|
||||
handleEvent g (VtyEvent (V.EvKey (V.KChar 'l') [])) = continue $ turn East g
|
||||
handleEvent g (VtyEvent (V.EvKey (V.KChar 'h') [])) = continue $ turn West g
|
||||
handleEvent g (VtyEvent (V.EvKey (V.KChar 'r') [])) = liftIO (initGame) >>= continue
|
||||
handleEvent g (VtyEvent (V.EvKey (V.KChar 'q') [])) = halt g
|
||||
handleEvent g (VtyEvent (V.EvKey V.KEsc [])) = halt g
|
||||
handleEvent g _ = continue g
|
||||
```
|
||||
|
||||
It's probably obvious, but `continue` will continue execution with
|
||||
the supplied state value, which is then drawn. We can also `halt` to
|
||||
stop execution, which will essentially finish the evaluation of our
|
||||
`customMain` and result in `IO Game`, where the resulting game is the
|
||||
last value that we supplied to `halt`.
|
||||
|
||||
#### Drawing
|
||||
|
||||
Drawing is fairly simple as well but can require a good amount of code
|
||||
to position things how you want them. I like to break up the visual
|
||||
space into regions with drawing functions for each one.
|
||||
|
||||
```haskell
|
||||
drawUI :: Game -> [Widget Name]
|
||||
drawUI g =
|
||||
[ C.center $ padRight (Pad 2) (drawStats g) <+> drawGrid g ]
|
||||
|
||||
drawStats :: Game -> Widget Name
|
||||
drawStats = undefined
|
||||
|
||||
drawGrid :: Game -> Widget Name
|
||||
drawGrid = undefined
|
||||
```
|
||||
|
||||
This will center the overall interface (`C.center`), put the stats and
|
||||
grid widgets horizontally side by side (`<+>`), and separate them by a
|
||||
2-character width (`padRight (Pad 2)`).
|
||||
|
||||
Let's move forward with the stats column:
|
||||
|
||||
```haskell
|
||||
drawStats :: Game -> Widget Name
|
||||
drawStats g = hLimit 11
|
||||
$ vBox [ drawScore (g ^. score)
|
||||
, padTop (Pad 2) $ drawGameOver (g ^. dead)
|
||||
]
|
||||
|
||||
drawScore :: Int -> Widget Name
|
||||
drawScore n = withBorderStyle BS.unicodeBold
|
||||
$ B.borderWithLabel (str "Score")
|
||||
$ C.hCenter
|
||||
$ padAll 1
|
||||
$ str $ show n
|
||||
|
||||
drawGameOver :: Bool -> Widget Name
|
||||
drawGameOver dead =
|
||||
if dead
|
||||
then withAttr gameOverAttr $ C.hCenter $ str "GAME OVER"
|
||||
else emptyWidget
|
||||
|
||||
gameOverAttr :: AttrName
|
||||
gameOverAttr = "gameOver"
|
||||
```
|
||||
|
||||
I'm throwing in that `hLimit 11` to prevent the widget greediness caused
|
||||
by the outer `C.center`. I'm also using `vBox` to show some other
|
||||
options of aligning widgets; `vBox` and `hBox` align a list of widgets
|
||||
vertically and horizontally, respectfully. They can be thought of as
|
||||
folds over the binary `<=>` and `<+>` operations.
|
||||
|
||||
The score is straightforward, but it is the first border in
|
||||
this tutorial. Borders are well documented in the [border
|
||||
demo](https://github.com/jtdaugherty/brick/blob/master/programs/BorderDe
|
||||
mo.hs) and the Haddocks for that matter.
|
||||
|
||||
We also only show the "game over" widget if the game is actually over.
|
||||
In that case, we are rendering the string widget with the `gameOverAttr`
|
||||
attribute name. Attribute names are basically type safe *names* that
|
||||
we can assign to widgets to apply predetermined styles, similar to
|
||||
assigning a class name to a div in HTML and defining the CSS styles for
|
||||
that class elsewhere.
|
||||
|
||||
Attribute names implement `IsString`, so they are easy to construct with
|
||||
the `OverloadedStrings` pragma.
|
||||
|
||||
Now for the main event:
|
||||
|
||||
```haskell
|
||||
drawGrid :: Game -> Widget Name
|
||||
drawGrid g = withBorderStyle BS.unicodeBold
|
||||
$ B.borderWithLabel (str "Snake")
|
||||
$ vBox rows
|
||||
where
|
||||
rows = [hBox $ cellsInRow r | r <- [height-1,height-2..0]]
|
||||
cellsInRow y = [drawCoord (V2 x y) | x <- [0..width-1]]
|
||||
drawCoord = drawCell . cellAt
|
||||
cellAt c
|
||||
| c `elem` g ^. snake = Snake
|
||||
| c == g ^. food = Food
|
||||
| otherwise = Empty
|
||||
|
||||
drawCell :: Cell -> Widget Name
|
||||
drawCell Snake = withAttr snakeAttr cw
|
||||
drawCell Food = withAttr foodAttr cw
|
||||
drawCell Empty = withAttr emptyAttr cw
|
||||
|
||||
cw :: Widget Name
|
||||
cw = str " "
|
||||
|
||||
snakeAttr, foodAttr, emptyAttr :: AttrName
|
||||
snakeAttr = "snakeAttr"
|
||||
foodAttr = "foodAttr"
|
||||
emptyAttr = "emptyAttr"
|
||||
|
||||
```
|
||||
|
||||
There's actually nothing new here! We've already covered all the
|
||||
`brick` functions necessary to draw the grid. My approach to grids is
|
||||
to render a square cell widget `cw` with different colors depending
|
||||
on the cell state. The easiest way to draw a colored square is to
|
||||
stick two characters side by side. If we assign an attribute with a
|
||||
matching foreground and background, then it doesn't matter what the two
|
||||
characters are (provided that they aren't some crazy Unicode characters
|
||||
that might render to an unexpected size). However, if we want empty
|
||||
cells to render with the same color as the user's default background
|
||||
color, then spaces are a good choice.
|
||||
|
||||
Finally, we'll define the attribute map:
|
||||
|
||||
```haskell
|
||||
theMap :: AttrMap
|
||||
theMap = attrMap V.defAttr
|
||||
[ (snakeAttr, V.blue `on` V.blue)
|
||||
, (foodAttr, V.red `on` V.red)
|
||||
, (gameOverAttr, fg V.red `V.withStyle` V.bold)
|
||||
]
|
||||
```
|
||||
|
||||
Again, styles aren't terribly complicated, but it
|
||||
will be one area where you might have to look in the
|
||||
[vty](http://hackage.haskell.org/package/vty) package (specifically
|
||||
[Graphics.Vty.Attributes](http://hackage.haskell.org/package/vty-5.15.1/docs/Graphics-Vty-Attributes.html)) to find what you need.
|
||||
|
||||
Another thing to mention is that the attributes form a hierarchy and
|
||||
can be combined in a parent-child relationship via `mappend`. I haven't
|
||||
actually used this feature, but it does sound quite handy. For a more
|
||||
detailed discussion see the
|
||||
[Brick.AttrMap](https://hackage.haskell.org/package/brick-0.18/docs/Brick-AttrMap.html) haddocks.
|
||||
|
||||
## Variable speed
|
||||
|
||||
One difficult problem I encountered was implementing a variable speed in
|
||||
the GoL. I could have just used the same approach above with the minimum
|
||||
thread delay (corresponding to the maximum speed) and counted `Tick`
|
||||
events, only issuing an actual `step` in the game when the modular count
|
||||
of `Tick`s reached an amount corresponding to the current game speed,
|
||||
but that's kind of an ugly approach.
|
||||
|
||||
Instead, I reached out to the author and he advised me to use a `TVar`
|
||||
within the app state. I had never used `TVar`, but it's pretty easy!
|
||||
|
||||
```haskell
|
||||
main :: IO ()
|
||||
main = do
|
||||
chan <- newBChan 10
|
||||
tv <- atomically $ newTVar (spToInt initialSpeed)
|
||||
forkIO $ forever $ do
|
||||
writeBChan chan Tick
|
||||
int <- atomically $ readTVar tv
|
||||
threadDelay int
|
||||
customMain (V.mkVty V.defaultConfig) (Just chan) app (initialGame tv)
|
||||
>>= printResult
|
||||
```
|
||||
|
||||
The `tv <- atomically $ newTVar (value :: a)` creates a new mutable
|
||||
reference to a value of type `a`, i.e. `TVar a`, and returns it in `IO`.
|
||||
In this case `value` is an `Int` which represents the delay between game
|
||||
steps. Then in the forked process, we read the delay from the `TVar`
|
||||
reference and use that to space out the calls to `writeBChan chan Tick`.
|
||||
|
||||
I store that same `tv :: TVar Int` in the brick app state, so that the
|
||||
user can change the speed:
|
||||
|
||||
```haskell
|
||||
handleEvent :: Game -> BrickEvent Name Tick -> EventM Name (Next Game)
|
||||
handleEvent g (VtyEvent (V.EvKey V.KRight [V.MCtrl])) = handleSpeed g (+)
|
||||
handleEvent g (VtyEvent (V.EvKey V.KLeft [V.MCtrl])) = handleSpeed g (-)
|
||||
|
||||
handleSpeed :: Game -> (Float -> Float -> Float) -> EventM n (Next Game)
|
||||
handleSpeed g (+/-) = do
|
||||
let newSp = validS $ (g ^. speed) +/- speedInc
|
||||
liftIO $ atomically $ writeTVar (g ^. interval) (spToInt newSp)
|
||||
continue $ g & speed .~ newSp
|
||||
|
||||
-- where
|
||||
|
||||
-- | Speed increments = 0.01 gives 100 discrete speed settings
|
||||
speedInc :: Float
|
||||
speedInc = 0.01
|
||||
|
||||
-- | Game state
|
||||
data Game = Game
|
||||
{ _board :: Board -- ^ Board state
|
||||
, _time :: Int -- ^ Time elapsed
|
||||
, _paused :: Bool -- ^ Playing vs. paused
|
||||
, _speed :: Float -- ^ Speed in [0..1]
|
||||
, _interval :: TVar Int -- ^ Interval kept in TVar
|
||||
, _focus :: F.FocusRing Name -- ^ Keeps track of grid focus
|
||||
, _selected :: Cell -- ^ Keeps track of cell focus
|
||||
}
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
`brick` let's you build TUIs very quickly. I was able to write `snake`
|
||||
along with this tutorial within a few hours. More complicated interfaces
|
||||
can be tougher, but if you can successfully separate the interface and
|
||||
core functionality, you'll have an easier time tacking on the frontend.
|
||||
|
||||
Lastly, let me remind you to look in the
|
||||
[demo programs](https://github.com/jtdaugherty/brick/tree/master/programs)
|
||||
to figure stuff out, as *many* scenarios are covered throughout them.
|
||||
|
||||
## Links
|
||||
* [brick](https://hackage.haskell.org/package/brick)
|
||||
* [snake](https://github.com/samtay/snake)
|
||||
* [tetris](https://github.com/samtay/tetris)
|
||||
* [conway](https://github.com/samtay/conway)
|
BIN
docs/snake-demo.gif
Normal file
BIN
docs/snake-demo.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 397 KiB |
Loading…
Reference in New Issue
Block a user