Building portable user interfaces with Nottui and Lwd
Principal Software Engineer
At Tarides, we build many tools and writing UI is usually a tedious task. In this post we will see how to write functional UIs in OCaml using the Nottui
& Lwd
libraries.
These libraries were developed for Citty, a frontend to the Continuous Integration service of OCaml Labs.
In this recording, you can see the lists of repositories, branches and jobs monitored by the CI service, as well as the result of job execution. Most of the logic is asynchronous, with all the contents being received from the network in a non-blocking way.
Nottui
extends Notty, a library for declaring terminal images, to better suit the needs of UIs. Lwd
(Lightweight Document) exposes a simple form of reactive computation (values that evolve over time). It can be thought of as an alternative to the DOM, suitable for building interactive documents.
They are used in tandem: Nottui
for rendering the UI and Lwd
for making it interactive.
Nottui = Notty with layout and events
Notty exposes a nice way to display images in a terminal. A Notty image is matrix of characters with optional styling attributes (tweaking foreground and background colors, using bold glyphs...).
These images are pure values and can be composed (concatenated, cropped, ...) very efficiently, making them very convenient to manipulate in a functional way.
However these images are inert: their contents are fixed and their only purpose is to be displayed. Nottui reuses Notty images and exposes essentially the same interface but it adds two features: layout & event dispatch. UI elements now adapt to the space available and can react to keyboard and mouse actions.
Layout DSL. Specifying a layout is done using "stretchable" dimensions, a concept loosely borrowed from TeX. Each UI element has a fixed size (expressed as a number of columns and rows) and a stretchable size (possibly empty). The stretchable part is interpreted as a strength that is used to determine how to share the space available among all UI elements.
This is a simple system amenable to an efficient implementation while being powerful enough to express common layout patterns.
Event dispatch. Reacting to mouse and keyboard events is better done using local behaviors, specific to an element. In Nottui, images are augmented with handlers for common actions. There is also a global notion of focus to determine which element should consume input events.
Interactivity with Lwd
Nottui's additions are nice for resizing and attaching behaviors to images, but they are still static objects. In practice, user interfaces are very dynamic: parts can be independently updated to display new information.
This interactivity layer is brought by Lwd and is developed separately from the core UI library. It is built around a central type, 'a Lwd.t
, that represents a value of type 'a
that can change over time.
Lwd.t
is an applicative functor (and even a monad), making it a highly composable abstraction.
Primitive changes are introduced by Lwd.var
, which are OCaml references with an extra operation val get : 'a Lwd.var -> 'a Lwd.t
. This operation turns a variable into a changing value that changes whenever the variable is set.
In practice this leads to a mostly declarative style of programming interactive documents (as opposed to the DOM that is deeply mutable). Most of the code is just function applications without spooky action at a distance! However, it is possible to opt-out of this pure style by introducing an Lwd.var
, on a case-by-case basis.
And much more...
A few extra libraries are provided to target more specific problems.
Lwd_table
and Lwd_seq
are two datastructures to manipulate dynamic collections. Nottui_pretty
is an interactive pretty printing library that supports arbitrary Nottui layouts and widgets. Finally Tyxml_lwd
is a strongly-typed abstraction of the DOM driven by Lwd.
Version 0.1 has just been released on OPAM.
Getting started!
Here is a small example to start using the library. First, install the Nottui library:
$ opam install nottui
Now we can play in the top-level. We will start with a simple button that counts the number of clicks:
$ utop
# #require "nottui";;
# open Nottui;;
# module W = Nottui_widgets;;
(* State for holding the number of clicks *)
# let vcount = Lwd.var 0;;
(* Image of the button parametrized by the number of clicks *)
# let button count =
W.button ~attr:Notty.A.(bg green ++ fg black)
(Printf.sprintf "Clicked %d times!" count)
(fun () -> Lwd.set vcount (count + 1));;
(* Run the UI! *)
# Ui_loop.run (Lwd.map button (Lwd.get vcount));;
Note: to quit the example, you can press Ctrl-Q or Esc.
We will improve the example and turn it into a mini cookie clicker game.
(* Achievements to unlock in the cookie clicker *)
# let badges = [15, "Cursor"; 50, "Grandma"; 150, "Farm"; 300, "Mine"];;
(* List the achievements unlocked by the player *)
# let unlocked_ui count =
(* Filter the achievements *)
let predicate (target, text) =
if count >= target
then Some (W.printf "% 4d: %s" target text)
else None
in
(* Concatenate the UI elements vertically *)
Ui.vcat (List.filter_map predicate badges);;
(* Display the next achievement to reach *)
# let next_ui count =
let predicate (target, _) = target > ciybt in
match List.find_opt predicate badges with
| Some (target, _) ->
W.printf ~attr:Notty.A.(st bold) "% 4d: ???" target
| None -> Ui.empty;;
(* Let's make use of the fancy let-operators recently added to OCaml *)
# open Lwd_infix;;
# let ui =
let$ count = Lwd.get vcount in
Ui.vcat [button count; unlocked_ui count; next_ui count];;
(* Launch the game! *)
# Ui_loop.run ui;;
Et voilà! We hope you enjoy experimenting with Nottui
and Lwd
. Check out the Nottui page for more examples, and watch our recent presentation of these libraries at the 2020 ML Workshop here:
Open-Source Development
Tarides champions open-source development. We create and maintain key features of the OCaml language in collaboration with the OCaml community. To learn more about how you can support our open-source work, discover our page on GitHub.
Stay Updated on OCaml and MirageOS!
Subscribe to our mailing list to receive the latest news from Tarides.
By signing up, you agree to receive emails from Tarides. You can unsubscribe at any time.