Skip to content

Instantly share code, notes, and snippets.

@jrpat

jrpat/libui.md Secret

Last active March 13, 2024 20:09
Show Gist options
  • Save jrpat/2baafceff655b209a3432a57c8069f3c to your computer and use it in GitHub Desktop.
Save jrpat/2baafceff655b209a3432a57c8069f3c to your computer and use it in GitHub Desktop.



Libui is a set of tools for writing User Interfaces in an enjoyable, robust way. It offers an expressive syntax that yields great markup & is easy to grok, a simple paradigm for sane state management, flexible components that are easily shared between applications, and good tooling to make your life easier.

Libui wants making great UI's to be fun :)

Contents

Introduction

Underlying Tech

Starting from the browser: (it helps to be familiar with all three, though you needn't be an expert)

  • HTML/CSS - oft-derided, but never equaled
  • React - the library, the myth, the legend. React is indeed pretty sweet.
  • Reagent - A simple-ish wrapper around React that facilitates UI components that auto-render based on some underlying state. Its biggest strength is its simple interface. We use reagent terminology (i.e. track)

With a big dose of ideas from (among other places):

Getting Started

The most common functions and macros are provided in one namespace:

(require '[libui.v2.core :as ui])

TODO: add a TL;DR example

Application State

All shared state lives in a central RAtom, informally called the "app-db", or just db.

The only way to modify the contents of the DB is by issuing a "command" (or cmd), for which there's a corresponding method defined on ui/execute-cmd:

;; Methods take in the db + any message arguments.
;; They return a new db.

(defmethod ui/execute-cmd :play-song [_ db song-id]
  (assoc db :current-song song-id))

                            ;; db is {:current-song nil, ...}
(ui/cmd! :play-song :foo)   ;; dispatch a msg with some arguments
                            ;; db is {:current-song :foo, ...}

You might write a lot of commands. To save some boilerplate, you could rewrite the above using the defcmd macro:

;; This is the exact same as the defmethod above

(ui/defcmd :play-song [db song-id]
  (assoc db :current-song song-id))

Commands are queued and batch-processed - in order - before the next render. Advanced topics like asynchronous operations are covered below in Cmd Patterns.

A few utility commands are provided by default:

(ui/cmd! :db/init {:foo {:bar 1}}]}

;; like `assoc-in`
(ui/cmd! :db/set [:foo :bar] 100)     ; db is {:foo {:bar 100}}

;; like `update-in`
(ui/cmd! :db/update [:foo :bar] inc)  ; db is {:foo {:bar 101}}

db paths work like assoc-in/update-in, but have additional facilities for pathing into lists of objects. See paths.

You can think of the application state - at any given time - as a reduction over all the commands that have been executed up to that moment. That gives us some really powerful benefits - not the least of which is making our code much simpler to reason about.

UI Components - ui/defui

Define a UI component that takes arbitrary arguments with defui:

(ui/defui name-tag [first last]           ;; This is a simple component. It will re-render
  [:div.tag                               ;; only when/if it receives different arguments.
   [:span.greeting "Hello. My name is"]
   [:span.name (str first " " last)]])

You can use a component just like any other reagent component or HTML element:

[:div.name-tag-container
 [name-tag "Foo" "Barre"]
 [:div.name-tag-badges
  [:b.badge-a "A"]
  [:b.badge-b "B"]]]

Tracking Application State

UI components can optionally track db paths:

;; This component tracks some application state. 
;; It will re-render whenever its tracked values change.
(ui/defui user-name-tag []

  :tracks [user [:session :current-user]]

  [:div.nametag
   [:span.greeting "Hello. My name is"]
    [:span.name (str (:first-name user) " " (:last-name user))]])

Additionally, UI components can track the output of any named function. We could refactor the above:

(defn full-name [user]
  (str (:first-name user) " " (:last-name user))

;; This component will re-render whenever the db value at [:session :current-user] changes.

(ui/defui name-tag []
  :tracks [user [:session :current-user]
           name (full-name user)]  ; just like `let`, you can use previous symbols immediately 
  [:div.nametag
   [:span.greeting "Hello. My name is"]
   [:span.name name]])

Note the above will re-render whenever the user changes. So if we change the user's :id, our component will re-render, even though the user's name hasn't changed. This isn't terrible (we'll return the same vector, and thus won't generate any DOM operations), but it's not ideal.

But wait! There's more!

Normal functions can track application state, too - using the with-tracks macro. We could refactor the above:

;; This function will be called whenever the user changes
(defn user-full-name []
  (ui/with-tracks [user [:session :current-user]]
    (str (:first-name user) " " (:last-name user))))

;; This component will only re-render when the user's full name changes! \o/
(ui/defui user-name-tag []
  :tracks [name (user-full-name)]
  [:div.nametag
   [:span.greeting "Hello. My name is"]
   [:span.name name]])

Derived data, flowing.

Reusable UI Components - ui/defcontrol

As our applications grow to even a moderate size, we quickly develop the need for UI components that can be reused in many different contexts in the same application, and even across applications. We need the component to be entirely self-contained and agnostic about its environment.

For these type of components, libui provides "Controls", defined with ui/defcontrol. Controls are the most robust and flexible type of components:

  • Controls do not take arbitrary args.
    • Instead, they take a single map of props, like other React components.
    • (Controls are usable in any React/Reagent application)
  • Controls do not track explicit application state.
    • Interaction with app state happens through data binding.
    • A control re-renders when an IReactiveAtom that it deref's changes. (or it gets new props)

Additionally,

  • Controls are encouraged to provide a Schema for their props, and get some benefits when doing so.
  • Controls can have children and can define named slots for more robust layouts.
  • Libui provides easy ways for controls to maintain private local state.

Defining a Control

Define a control using ui/defcontrol:

(require '[libui.v2.schema :as s])

;; Here's a maximal(-ish) example of a Control.
;; We'll go through it in depth below.

(s/defschema MyDropdownProps
  {:style   s/Str                 ; a dummy property
   :visible (s/binding s/Bool)})  ; whether or not to show contents


(ui/defcontrol my-dropdown [{:keys [style visible]}]
  :props MyDropdownProps
  :slots [toggler menu]
  :state [counter (ui/ratom 0)]

  [:div.dropdown {:class (str "style-" style)}
   [:div.toggle-container {:on-click #(do (swap! visible not)    ; gasp! what is this heresy?
                                          (swap! counter inc))}
    toggler]

   (when @visible                                                ; why is this being deref'd?
     [:div.menu-container
      [:small (str "Opened " @counter " times.")]
      [:ul.menu
       menu]])])


;; An end-user might use this control like this:
[:div
 [my-dropdown {:style :popup
               :default/visible true}
  :toggler [:button "Click Me!"]
  :menu [[:li "Foo"]
         [:li "Bar"]
         [:li "Baz"]]]]

Let's go through each part in depth.

Definition and Props

Just like defn or defui, the first argument to defcontrol is a name, and the second argument is an arg vector. The first (and only) arg a control receives is a map of props.

The next arguments are optional keyword arguments, and can be passed in any order. Valid options are:

  • :props - define a schema for your props. Also enables data-binding.
  • :slots - defines named "slots" into which children can be placed
  • :state - puts some local state into the lexical scope of your render body

:slots and Children

One important function of controls is containing other page UI elements. You can nest DOM and other components inside a Control, just like normal. Retrieve them inside the render function with ui/children:

(ui/defcontrol my-control []
 [:div.myctrl
  (ui/children)])

A few things to note:

  • (ui/children) will always return a seq of children.
  • Each child in the sequence will have a :key assigned if it doesn't have one already.
    That means you can place the seq directly inside some DOM like [:div (ui/children)] without getting warnings from reagent/react.

Slots

Nesting children is useful, but as we build more complex layouts, the need for a more flexible approach arises. Let's say you have a Control which wants to output DOM like this (where each ... should be some children):

[:div.my-control
 [:div.header ...]
 [:div.sidebar ...]
 [:div.body ...]
 [:div.footer ...]]

How would you take in children elements for each section? One approach might be to take components as props. That works, but gets unwieldy fast. Another approach would be to break up the Control into smaller chunks & have the end-user use each one individually. That works, but adds cognitive overhead to the end-user, which we aim to avoid.

To make this easier, Controls can use the :slots option:

(ui/defcontrol my-control []
  :slots [header sidebar body footer]
  ;; ...
  )

Then an end-user can pass children directly into a particular slot, like this:

[:div
 [my-control
  :header  [:h1 "This is the Title"]
  :sidebar [blog-sidebar-menu]
  :body    [:article.post (blog-post-contents)]]
  :footer  [:nav.footer-links
            [:a {:href "/home"} "Home"]
            [:a {:href "/logout"} "Log Out"]]]]

A few things to note:

  • Just like (ui/children), the contents of a slot will always be a seq.
  • Just like (ui/children), each child will all have a :key defined.

:props - Define Props Schema

It's good practice to define a schema for your props. If you provide one, libui will do a few things for you:

  • Strip any props that aren't in your schema
    (helpers are provided for standard DOM events & attributes)
  • Check runtime props, error if they are invalid
    (can be turned off in production by setting a compile-time var)
  • Enable data-binding for props with {:bindable true} in their type's metadata.
    (the s/binding helper does this for you)
  • Display inputs for your props on the toolkit page

To make life easier, libui provides the libui.v2.schema namespace. Note the first line above:

(require '[libui.v2.schema :as s])

This namespace exposes all of schema.core + adds a few UI-specific helpers. See libui.v2.schema for more.

Data Binding - the approach

Controls don't track explicit application state, but obviously they aren't of any use if they can't read/write application state at all. Instead of directly tracking state and executing commands, controls do their state-manipulation through data-binding.

Earlier we said that the only way to modify the db was by using ui/cmd! - and that we got a lot of benefits by adopting that paradigm. It's all still true. But for reusable controls, the paradigm of taking an IReactiveAtom as a prop is very useful:

;;;; Why take 2 props...

(s/defschema MyControlProps
 {:visible-val s/Bool
  :visible-cmd s/Keyword})

[:div {:on-click #(ui/cmd! visible-cmd (not visible))}]  ; ugly! also not usable outside libui!
(when visible-val [:div ... ])


;;;; ... when 1 prop will do?

(s/defschema MyControlProps
 {:visible s/IReactiveAtom})

[:div {:on-click #(swap! visible not)}]  ; lovely! and usable outside libui!  \o/
(when @visible [:div ... ])

Libui brings the two approaches together with (ui/binding), which is a special kind of Reagent Cursor that doesn't modify its source data (the app-db) directly, but rather executes a :db/set Command with the new value:

;; Normal Cursor:
(let [x (r/cursor ui/appdb [:some :nested :path])]
  (reset! x 100)   ;; => modifies db directly -> {:s {:n {:p 100}}}
  (swap! x inc))   ;; => modifies db directly -> {:s {:n {:p 101}}}

;; ui/binding:
(let [x (ui/binding [:some :nested :path])]
  (reset! x 100)  ;; => executes (ui/cmd! :db/set [:s :n :p] 100)
  (swap! x inc)   ;; => executes (ui/cmd! :db/update [:s :n :p] inc)
)

That means we can have our cake & eat it, too. We get the decoupling & convenience benefits of cursors, but the data-flow benefits of the "database-as-reduction-over-commands" approach.

Data Binding - syntax

To specify one of your props as a binding, use the s/binding helper:

(require '[libui.v2.schema :as s])

(s/defschema MyProps
 {:visible (s/binding s/Bool)})

;; This is equivalent, but longer:
(s/defschema MyProps
 {:visible ^{:bindable true} (s/IReactiveAtom s/Bool)})

So a bindable prop will always be an coerced into an IReactiveAtom containing the type specified in the schema.

(ui/defcontrol my-control [{:keys [visible]}]
  :props MyProps
  (when @visible
    [:div {:on-click #(reset! visible false)}
     "HIDE ME"]))

An end-user of this Control can pass an IReactiveAtom directly passing the prop with a :bind/ namespace:

;; To pass a binding directly, prefix the prop with the :bind/ namespace
[:div
 [my-control {:bind/visible (ui/binding [:some :path])}]]

To pass a fixed value (like a Controlled Component in React), use the prop with no namespace:

;; To use a fixed value, pass the prop with no namespace.
;; The coerced prop will be read-only, so reset!'ing or swap!'ing 
;; it will have no effect (ie. will not cause a re-render)
[:div
   [my-control {:visible true}]

To set a default value, use the prop with the :default/ namespace:

;; To use a default value, pass the prop with the :default/ namespace.
;; The resulting prop serves as private state inside the render function.
;; reset!'ing and swap!'ing work as expected (ie. will cause a re-render).
[:div
   [my-control {:default/visible true}]]

:state - Local Private State

Reagent provides facilities, but the syntax can be tedious and error-prone (see "rookie mistake" in the link above). The :state option makes this simple for controls. :state syntax is a vector of bindings to be used in a let:

(ui/defcontrol myctrl []
  :state [counter (ui/ratom 0)]
  [:div  
   [:button {:on-click #(swap! counter inc)}]
   [:div (str "Button has been pressed " @counter " times."]])
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment