Recently when I was building development UI for my game, I came to realize that GUI is a better expressiveness test for Dew than a mob AI.
For example, Immediate Mode Graphical User interface (IMGUI) libraries and my game share a same important problem to solve: mapping an identity in user/script code to an internal state in the library/engine. IMGUI libraries solve this problem by requiring the user code to specify an unique identifier, or a tree path of unique identifiers for more complex situations; while in my game, I can use a Dew stream to retain a handle to the internal state.
To test the expressiveness of Dew, I consider a much more complex approach of GUI than IMGUI: building GUI as a hierarchy of components, with all components being retained, and each component’s behaviours being defined locally.
React.js is one popular GUI library with such approach. In this blogpost, I’ll consider a programming model close to React.js, and have the following capabilities defined for components:
There is a global state, which can be accessed by all components;
Each component’s lifetime is determined by its parent;
Each component receives a set of parameters from its parent, and some of the parameters can be time-varying;
Each component can have its own state;
Each component can send back a value to its parent component for feedback, which is often related to user inputs.
Channels
Building GUI requires a planned language feature of Dew: channels.
For the current version of Dew, I’ve always a bit concerned that data flow can be somewhat too restricted for some use cases. If wiring data through FFI is not counted, then
at the time when a Dew stream is created, all of its future input must be completely defined, possibly in terms of unknown future user inputs;
also, a Dew stream can only output through by its evaluated/returned value at each frame.
These restrictions are related to the language design that data mutation isn’t allowed. Data mutation across different Dew frames conflicts with the notion of Dew stream, since both of them can represent a varying value over time, and allowing both of them would lead to confusions. Another concern with data mutation is how it works work Dew streams evaluated concurrently.
To solve the problem, I’ve come up with the notion of channels recently. Channels can elegantly solve the problem of more flexible data flow across different Dew streams, while introducing minimal drawbacks.
Channels have two variants, and can be created by calling one of the following two functions:
chan: () -> (''a -> (), *''a)
dchan: () -> (''a -> (), *[''a])
When the function is called as chan ()
or dchan ()
it returns a two-element tuple (in,out)
, represents the two endpoints of the channel:
The first element
in
is the input/write-end of channel. Writing some valuex
to the channel is simply a function callin x
.The second element
out
is the output/read-end of channel. To read from the channel, simply evaluate the streamout
as@out
.
Note that only two primitive functions are introduced, and we did not introduce any new language constructs. Channel endpoints can be freely passed around like a normal function or stream, and can also be passed through another channel.
The channels look very powerful at this point, but I haven’t yet explained why there’re two variants of them. In our design, channel output endpoints are represented as streams. To evaluate the stream and get channel output, we’ll have to wait for all other streams, because they could all potentially hold the input endpoint, and send some values into the channel during evaluation. This introduces a lot of data dependencies, and would have a big impact on parallelizability of streams.
So instead of the naive version of channels, we split it up into two variants, each with a different restriction to keep data dependency minimal.
With the variant of
chan
, we require that exactly one value is sent into the channel, for each frame that the output stream is alive and evaluated. It would be a runtime error if this restriction is violated. With this variant, the output stream is ready to be evaluated as soon as we see the first value sent into the channel, which location of this event is unknown though.With the variant of
dchan
, we do not put restriction on the number of values sent into the channel, but a value sent into the channel may be delayed for a frame. The exact rule fordchan
is a bit complex because we need to work with the notion of “early streams” in Dew, so that it would be possible to process feedbacks collected in the current frame without delay.Early streams are a distinguished set of streams that are intended to fetch feedback from the outside world between consecutive two Dew time frames. At the beginning of each frame, the runtime has an early phase that tries to evaluate early streams, and the normal phase is after that.
A value sent into a
dchan
during normal phase is delayed to the beginning of normal phase in the next frame; while a value sent into adchan
during early phase is immediately available at the beginning of normal phase in the current frame. Output stream ofdchan
is always not available during the runtime early phase; but is immediately available at the beginning of runtime normal phase, without dependency on any other stream.
There are other variants of channels possible, but combination of chan
and dchan
seem good enough for now, and I may add more later if needed.
I expect that we can build some higher level constructs with the channels, but this is not the focus of this blogpost.
Basic GUI widgets in Dew
To implement this GUI model in Dew, we assume that a context ctx
needs to be serially wired through each component. This context is internal to the GUI library, and will notably contain information necessary for layout. Then a GUI component can be represented as a Dew value of the following type:
*ctx -> (*ctx, *''a)
In this type, ''a
is the type of feedback value that the component generates at each frame. A component’s lifetime starts when it’s instantiated by applying a *ctx
value, and ends when the returned *ctx
is no longer referenced. The component’s parameters and states can be stored as the upvalues, even though we haven’t explicitly modeled them.
The returned *ctx
and *''a
are two separate streams, because the feedback stream *''a
only depends on the previous value of *ctx
and the current value of user inputs.
We can then define a set of basic GUI widgets:
button: *string -> *ctx -> (*ctx, *bool)
label: *string -> *ctx -> (*ctx, *())
textbox: *string -> *ctx -> (*ctx, *string)
window: (num,num,num,num) -> (*ctx -> (*ctx, *''a)) -> *ctx -> (*ctx, *''a)
For buttons, the feedback value indicates whether the button is pressed; and for textboxes, the feedback value represents the next text as modified by user inputs.
button
can be implemented as follows, and other widgets are similar.
button caption ctx =
let ctx' = pre ctx0 ctx in
let state' = pre state0 state
and state = dew -> button_next_state @state' @ctx in
let render = dew -> draw_button @ctx @caption @state in
(dew -> let _ = render in layout_button @ctx @caption),
(dew -> is_mouse_pressed @ctx')
Rendering code for the button is placed in a separate stream than ctx
, so that rendering for each widget is parallelizable, while ctx
has to be serially wired through each widget.
A widget’s parameter can directly depend on its feedback, as demonstrated as follows:
let textbox' ctx =
let newctx, text = textbox text ctx in
newctx, text
textbox'
is a new widget like textbox, except that its content can only be modified by user input, and can’t be controlled programmatically.
Manipulating feedbacks
In many cases we’ll want to capture the feedback value of a component when it’s alive, and do something with it, like sending it into a channel. We define a the helper function getret
for this purpose:
# getret : (''a -> ()) -> (*ctx -> (*ctx, *''a)) -> *ctx -> (*ctx, *())
getret f component ctx =
let newctx, feedback = component ctx in
let aux = dew' -> f @feedback in
(dew -> let _ = aux in @newctx),
(dew -> ())
Note that an early stream dew' ->
instead of dew ->
is used. This forces the feedback stream to be evaluated and the feedback value to be sent into the channel, as long as the component is retained in the last frame or has just been created. Otherwise, it could potentially introduce cyclic dependence that whether a component should be retained or not depends on its feedback, which in turn depends on the component being retained.
To change the original feedback value, we also define a mapret
function:
# mapret : (''a -> ''b) -> (*ctx -> (*ctx, *''a)) -> *ctx -> (*ctx, *''b)
mapret f component ctx =
let newctx, feedback = component ctx in
newctx, (dew -> f @feedback)
Building up larger components
From basic widgets, we can build up larger GUI components by layout functions. We consider two simple layout functions here:
layout_row: num -> [num, *ctx -> (*ctx, *''a)] -> *ctx -> *()
layout_cat: [*ctx -> (*ctx, *''a)] -> *ctx -> *()
layout_row
builds up a row of components, with the height of the row specified by the first argument, and width of each child components specified by the first tuple element in the list. layout_cat
builds up a larger component from a series of rows.
These layout functions are static, and does not allow adding, removing or reordering child components dynamically. The child components passed to these layout functions will be instantiated when the larger component is instantiated.
This design is chosen because *ctx
argument must be serially wired by layout functions, so a child component has to be passed as an uninstantiated form *ctx -> (*ctx, *''a)
, which makes it unclear what its lifetime is if it’s dynamic. It’s possible to add a unique identifier for each child component in the list to specify that, but for a simple layout function we’ll ignore that possibility.
To give a small example with static-layout components, let’s make an incrementor:
let incrementor =
let setbtn, btn = chan () in
let state = pre 0 state + (dew -> if @btn then 1 else 0) in
layout_row 10 [
30, label (tostring state);
10, getret setbtn $ button "Increment";
]
Note that states of a component can be simply created by pre
operator. Global states are also easy to do:
let send_globalfeedback, globalfeedback = dchan ()
let globalstates_last = pre globalstates_initial globalstates
and globalstates = dew ->
calculate_globalstates globalstates_last globalfeedback
Dynamic layout
Now we consider how to add/remove/relayout child components in a larger component dynamically. When we do this across different Dew time frames, some of the old components should be retained, and some new components should be instantiated.
An obvious way to do this explicitly in Dew would be using state machines, which I explained in an earlier blogpost. The retained component instances can be passed among different state machine states as state parameters.
But there’s one problem. When the component among different state machine states, some of the child component instances are retained. So at the moment that a child component is instantiated, it must be given a ctx
stream that could potentially come from multiple state machine states.
To solve the problem, we need to use a channel:
# instantiate : (*ctx -> (*ctx, *''a)) -> *ctx -> (*ctx, *''a)
instantiate component =
let setctx, ctx = chan () in
let newctx, feedback = component ctx in
fun ctx' ->
(dew -> setctx @ctx'; @newctx),
feedback
The channel is created at the moment when the component is instantiated. To supply the channel, instantiate
returns a fake component that intercepts ctx
and send it through the channel.
Now we focus on a simple state machine with only one parameterized state to emulate the style of React.js, and make a simple Todo list GUI as the final example.
# withstate: (''s -> ((''s->''s)->()) -> *ctx -> (*ctx, *())) ->
# ''s -> *ctx -> (*ctx, *())
withstate f =
let component state ctx =
let setstate, statemut = dchan () in
let newctx, feedback = f state setstate ctx in
(switch $ dew ->
if @statemut == [] then @newctx\ else begin
let newstate = foldl (fun a f -> f a) state @statemut in
\component newstate ctx
else),
(dew -> ())
in component
onclick f =
getret (fun c -> if c then f ())
let todolist = withstate $ fun state setstate ->
let settext, text = chan () in
layout_cat $ lappend
(lmapi (fun item i -> layout_row 10 [
50, item;
10, onclick (fun () ->
setstate $ fun state -> lremove state i
) $ button "Remove Item";
]) state)
[layout_row 10 [
50, getret settext $ textbox';
10, onclick (fun () ->
setstate $ fun state ->
lappend state [instantiate $ label @text]
) $ button "Add Item";
]]
The state is a list of instantiated label
components, and whenever we want to modify the state, we can call setstate
.
There’s a minor issue with the code, if there’re multiple click events happen at the same, the state mutation function given to setstate
may not be correct. To fix this issue, we can: 1) make sure there won’t be multiple simultaneous user input events; 2) do not mutation the state if it would cause a runtime error; 3) a map instead of a list is used for state
; or 4) we can make withstate
accept only the first state mutation.
Conclusion
In this blogpost, I’ve described a GUI framework similar to React.js. By filling in the missing blanks, I expect that a full-featured GUI library can be built. This GUI library would introduce no unnecessary delay, and has parallelized rendering.
Comparing my framework with React.js, I see two advantages with Dew:
If the goal is build the same ‘component’ abstraction, Dew do not have many advantages. However with Dew I can have much finer-grained abstractions than ‘component’. These finer-grained abstractions are so simple, that I expect much more abstractions are possible.
Data flow and delay are much clearer in Dew than in React.js. In Dew, data flow and delay are explicit; while I wouldn’t expect to understand how React.js works.
It’s interesting to think that React.js turns to use fibers in newer versions, while it’s the default evaluation strategy for Dew programs.
Another popular GUI framework is the Elm language, which was first designed as a way to build web-based GUIs with functional reactive programming (FRP). However unfortunately, they later decided to abandon FRP after it was realized that streams (or signals in Elm’s terminology) does not help much. I suppose this is because the streams in Elm are much weaker in expressiveness than Dew.
By thinking how to do GUI in Dew, I learned a lot along the way:
Channel is indeed a very powerful mechanism for flexible data flow! I only thought of a simpler variant similar to
dchan
originally, but now better variants are designed, making Dew channels even more powerful.I wasn’t sure whether early streams should be guaranteed to evaluate when they’ve just been created. Now I think it should, so that they have the property that they’ll always be evaluated when they are alive. Otherwise
getret
may fail to deliver the first feedback value.Polymorphic variants like those in OCaml are useful to represent feedback events.
A
string
type can be useful for building GUIs.
For now, I don’t intend to build development UI for my game in Dew, because that would force me to expose more details of the engine to the scripting facility. But this could be interesting option to explore in the future!