-
Notifications
You must be signed in to change notification settings - Fork 0
Reactive UI
If you’re familiar with QML, ReactJS then it should be quite similar to their ideas.
We are using a scripting language to describe UI via declarative syntax as data.
One describes UI element tree only once and further on the tree would be changed only as reaction on the events, or more strictly as reaction on change of the data it depends on.
As an example let’s explicitly see the difference in data flow in regular case and reactive:
a=4
c=a+2
a=1
c=?
In regular case c=6 but in reactive one c=6 at the initialization and when ’a’ changes it triggers recalculation of ’c’ and c becomes 3.
Well, originally I wanted to try Scheme but there are few information about how to write a macros and even less a good one.
On daily job I’ve used Quirrel and it’s pretty good language for such tasks (dynamic typed language with high order functions).
But I wanted to try something new and step up on QuickJS VM.
Javascript allows us to use any language that got translated to the javascript as the end point,
thus it makes possible to use language like ClojureScript (with a pain from it’s integration) which is
quite interesting thing to deal with.
But it can’t be done until one have a good framework based on QuickJS so as a compromise I took typescript for the toy renderer.
The only big problems you have to deal with are:
- problems with compilation under windows
- requirements to study how javascript works under the hood to understand QuickJS VM usage
- sources as the only point of documentation
So, below would be a description of the framework via examples.
Let’s start with simple static elements that does not depend on reactive data.
Here UI tree constructed once and remains the same all the time
import * as ui from "@ui"
globalThis.rootUI = {
size: [500,500],
pos: [0, 0],
render: ui.RENDER_BOX,
color: [255,255,255,255],
halign: ui.HALIGN_CENTER,
valign: ui.VALIGN_CENTER,
flow: ui.FLOW_FLEX_ROW,
gap:50,
childs: [
{
flex:1,
render: ui.RENDER_BOX,
color: [255, 0, 0, 50]
},
{
flex:2,
render: ui.RENDER_BOX,
color: [0,0, 255, 80]
}
]
}
Elements described via maps where each element might have 0 to many childs.
Each element must specify it’s size if parent doesn’t declared the flow, other params are optional.\
Final tree must be initialized to globalThis.rootUI
All params should be self explaining (flex is taken from html flex container thus if flow specified, children will have a filled size split by flex value)
ui.RENDER BOX, and other ui.* values are constants.
If render is specified then element will be rendered via declared renderer.
So, the script above declares an element with size [500,500] that placed at the center of the parent (screen) which childs will be placed in flex row container with gap=50. First child takes 1/3 of available size second one taks 2/3 of the size.
Let’s go further and add states with dynamic elements.
To be able to reconstruct an element we need to handle it’s state and observe the changes somehow.
What do we need?
- During a construction or destruction of reactive state object in JS one need to execute C++ logic
that will start or end observing it. - Reactive js state must have an implicit field value and getter,setter to that field thus during
setter execution on C++ side it would be marked as dirty.
Dynamic element must be constructed via lambda or function that will be used as element constructor.
Every dynamic element must have a new field observe that stores a references to the dynamic states of dynamic element it depends from.
Thus, during UI tree construction if an element described via function or lambda:
Function got executed and the resulting static element will be saved. Additionally from the static
element one reads observe field to gather all dynamic states it depends from. Function-constructor
will be saved and re-executed each time the dynamic state is changed. That’s all the trick to make it
working
import * as ui from "@ui"
let testState = new ui.ReactState(0)
let i = 0
ui.addTimer("test_timer", 1 , () => {
testState.value = i % 2 ? true : false
i += 1
})
globalThis.rootUI = {
size: [500,500],
pos: [0, 0],
render: ui.RENDER_BOX,
color: [255,255,255,255],
halign: ui.HALIGN_CENTER,
valign: ui.VALIGN_CENTER,
flow: ui.FLOW_FLEX_ROW,
gap:50,
childs: [
() => ({
size: [50, 50],
halign: ui.HALIGN_CENTER,
valign: ui.VALIGN_CENTER,
observe: [testState],
render: ui.RENDER_BOX,
color: testState.value ? [255, 0, 0] : [0,0, 255]
})
]
}
Here we have a dynamic element with size 50x50 pixels that observes a testState which is changed
once per 1 sec via timer. Once per second testState sets a new value and gets marked as dirty. Since
testState is dirty a ctor function of 50x50 quad will be called to reconstruct the element. Thus we
have a centered quad which changes it color once per second, yay!
Behavior is an additional element controller that has an access to the element’s data and able to manipulate with it the way it wants. It might be an input listener or to be used by other Behaviors or GUI VM itself during rendering for example.\
Behaviors is a main tool in construction of the smart UI parts like : Buttons, Input Area, Scroll, Drag&Drop, Multi-line Text and the others.
Toy Renderer
supports only one behavior : Button
Button Behavior is an input handler(Basically it just sets a bit that allows to gather these events
from VM) that handles mouse events and updates observeBtnState field with current button state
and calls onClick callback. P.s. observeBtnState is quite misleading since it does not observe the
state in a way reactive system does, but I’ve not refactored it yet.
Here is an example, where we have a quad button that changes it size, color via button state and adds logerror on click:
It worth to mention that during input event processing every behavior that is marked as input
handler will receive an input event. Ordering of the behavior processing strictly depends on depth
order of the UI element is owned by behavior i.e. top to down. During UI tree construction we’ve got
an Input and Render stacks with sorted order. Just like in the input router listeners, Input Behavior
is able to consume the event and prevent it from processing by the other elements down the way. For
example it makes possible to create a click-through buttons.
import * as ui from "@ui"
import {logerror} from "@log"
let btnState = new ui.ReactState(0)
globalThis.rootUI = {
size: [500,500],
pos: [0, 0],
render: ui.RENDER_BOX,
color: [255,255,255,255],
halign: ui.HALIGN_CENTER,
valign: ui.VALIGN_CENTER,
flow: ui.FLOW_FLEX_ROW,
gap:50,
childs: [
() => ({
size: [50, 50],
halign: ui.HALIGN_CENTER,
valign: ui.VALIGN_CENTER,
observe: [btnState],
observeBtnState: btnState,
behaviors: [ui.BEHAVIOR_BUTTON],
render: ui.RENDER_BOX,
color: btnState.value & ui.BUTTON_HOVERED ? [255,255,255] : [0,0,255],
onClick: () => logerror("clicked")
})
]
}