Getting Started With Incr_dom - Part 2
Note: Incr dom is superceded by a new, much easier to use library called Bonsai
This installment will add the basic frame of the single page application. We’ll create a menu and be able to navigate between the various pages. In later chapters we’ll fill in the contents of each of the pages. The result of this part is shown below (interactive):
Prerequisites
In order to give our application a bit of polish we’ll be using a CSS-only framework to go with our application. Bulma.io is a CSS framework that comes with many different pre-designed components for use in any application. Being CSS only makes it easy to integrate into Incr_dom, since Incr_dom wants full control over state handling. There are escape hatches, but it can get messy. For our purposes download the release from here. Find the bulma-0.7.5/css/bulma.min.css
file in the zip and copy it to the root of the Incr_dom project.
Now update the index.html file to include a reference to this CSS file after the <meta>
tag:
<link rel='stylesheet' type='text/css' href='bulma.min.css'>
It will also be helpful to have a Makefile
to shorten the build cycle a bit. Here’s an example one you can use:
all:
dune build bin/main.bc.js
show:
firefox index.html
clean:
dune clean
Now you can type make
at the terminal to build instead of dune build bin/main.bc.js
.
Adding A Main Menu
We’ll add a main menu that the user can use to get to any other sub-page. We’ll add the following items:
- New Game
- High Scores
- Credits
Open lib/tetris.ml
and add a new variable called main_menu
above the view
function:
let main_menu =
let open Vdom in
let open Node in
let open Attr in
let field content =
div [class_ "field"] [
div [class_ "control"] [
content
]
]
in
let menu_button content =
button [
classes ["button"; "is-info"; "is-centered"];
style Css_gen.(
display `Block
@> margin ~bottom:(`Rem 0.5) ()
@> width (`Percent Percent.(of_percentage 100.))
)
] [text content]
in
div [classes ["container"; "has-text-centered"]] [
div [class_ "content";
style Css_gen.(
width (`Percent Percent.(of_percentage 25.))
@> display `Inline_block
@> padding ~top:(`Rem 2.) ()
)
] [
div [classes ["has-text-centered"]] [
h1 [] [text "Tetris"];
];
field (menu_button "New Game");
field (menu_button "High Scores");
field (menu_button "Credits");
]
]
;;
Then update the view function to use this variable instead of returning a Hello World!
div:
let view model ~inject =
let open Incr.Let_syntax in
let%map model = model in
main_menu
;;
Compling and rebuilding should look something like this:
Unfortunately it’s a fair amount of code to do what seems like a simple layout. An outer container div
is marked inline-block
so text-align: centered
can be used on it (the Bulma.io has-text-centered
CSS class). This centers the menu. Then the Tetris
title is wrapped again to center it inside the menu div. Each button is wrapped in a Bulma.io field to get some nice padding and vertical layout. is-info
is used on each button to give it the blue color (default is white). Nothing exciting yet, but now we’re ready to add some sub-pages.
Adding A Sub-Page
There will be a couple steps we need to do to get sub-pages working properly. We’ll need to keep track of which page we’re on in the model. We’ll need a way to change pages, and we’ll need a way to route to the right sub-page when a link is loaded. And of course the view function needs to be updated to show the correct page. To tackle the first task lets create a new sum type that represents the different sub-pages and add it to the model. Update the model to look like:
type page =
| MainMenu
| NewGame
| HighScores
| Credits
[@@deriving sexp, compare]
type t = {
current_page : page;
}
[@@deriving sexp, compare]
let init = {
current_page = MainMenu
}
let compare = ...
Now we can keep track of the different pages in the model, but we’ll need a way to change between pages and update the model. This is where the Action
model and inject
function comes into play. Update the action type to be:
type t = ChangePage of Model.page
[@@deriving sexp]
and update the apply_action
function to be:
let apply_action (model : Model.t) (action : Action.t) _ ~schedule_action =
match action with
| ChangePage new_page ->
Model.{ current_page = new_page }
;;
Now let’s update the buttons on the main menu to navigate to the proper page. The revamped main_menu
code looks like:
let main_menu inject =
(* ... *)
let field content = (* ... *)
let menu_button content (page : Model.page) =
button [
(* ... *)
on_click (fun _ -> inject Action.(ChangePage page));
] [text content]
in
div [classes ["container"; "has-text-centered"]] [
(* ... *)
field (menu_button "New Game" Model.NewGame);
field (menu_button "High Scores" Model.HighScores);
field (menu_button "Credits" Model.Credits);
]
]
;;
The main change is an on_click
attribute has been added to the buttons which calls the inject
function with the appropriate Action.t
to change the page. The inject
function returns a Vdom.Event.t
which is intercepted by the Incr_dom
framework and queues a call to the apply_action
function for updating the model. Incr_dom
is written in such a way that it will update the model as much as possible before rendering. So you don’t need to worry about actions causing unnecessary renders.
To actually see something when a button is clicked, a view for each sub-page needs to be created. We’ll keep it simple and display a header for each sub-page and a back button. We’ll wrap this up in a helper function so each sub-page provides its content, but each page will have the same header style and behavior. Create the helper function subpage
somewhere above main_menu
:
let subpage page_name content inject =
let open Vdom in
let open Node in
let open Attr in
let outer_padding =
let pad = `Rem 0.5 in
Css_gen.(padding ~top:pad ~bottom:pad ~left:pad ~right:pad ())
in
div [class_ "container"] [
div [class_ "content";
style outer_padding]
[
div
[style Css_gen.(display `Inline_block)]
[h2 [] [text page_name]];
button
[classes ["button"; "is-info"];
style Css_gen.(float `Right);
on_click (fun _ -> inject Action.(ChangePage MainMenu))]
[text "Back"];
content;
]
]
;;
Pretty simple helper that will wrap up any content on the page with a header of the page name and a back button that goes back to the main menu. Lets create a variable for each of our subpages:
let new_game_page =
subpage "New Game" Vdom.Node.(div [] [])
;;
let high_scores_page =
subpage "High Scores" Vdom.Node.(div [] [])
;;
let credits_page =
subpage "Credits" Vdom.Node.(div [] [])
;;
And finally let’s update the view
function to display based on the Model
’s current_page
field:
let view model ~inject =
let open Incr.Let_syntax in
let%map current_page =
model >>| (fun m -> m.Model.current_page)
in
match current_page with
| MainMenu -> main_menu inject
| NewGame -> new_game_page inject
| HighScores -> high_scores_page inject
| Credits -> credits_page inject
;;
Compiling and reloading should give working links! Here we can see that we projected out only the current_page
variable. It doesn’t really matter now, since the model only has one field, but shows the syntax for projecting out sub-parts of the Model
.
Conclusion
This part added a menu with some sub-pages and introduced the Bulma.io CSS framework. The next part in the series will start adding functionality to the different sub-pages. Code for this part can be found here.