Building portable user interfaces with Nottui and Lwdby Frédéric Bour on Sep 24th, 2020
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
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.
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.
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.
A few extra libraries are provided to target more specific problems.
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.
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
Lwd. Check out the Nottui page for more examples, and watch our recent presentation of these libraries at the 2020 ML Workshop here: