Skip to content

Denim UI

A custom cross platform UI framework focused on fast and easy prototyping by the use of a custom DSL.

The various platforms are supported through separate backend implementations. An js backend that uses the canvas api for drawing and html for native components the only somewhat usable backend, but a native one is planned.

Warning

Note that this project is still in an early phase, and subject to possibly large changes and breakages. Use at your own risk.

Minimal example

minimal

import denim_ui
import denim_ui_canvas

proc render(): Element =
  panel:
    rectangle(
      color = colCadetBlue,
      radius = (10.0, 2.0, 10.0, 2.0),
      width = 150,
      height = 60,
      alignment = Alignment.Center
    )
    text(
      text = "Hello world!",
      fontSize = 14.0,
      color = colWhite,
      alignment = Alignment.Center
    )

startApp(
  render,
  "rootCanvas",
  "nativeContainer"
)
<html lang="en">
    <body>
        <div id="nativeContainer">
            <canvas id="rootCanvas"></canvas>
        </div>
    </body>
    <!-- the bundle output by nim -->
    <script type="text/javascript" src="./dist/bundle.js"></script>
</html>

Installation

Denim UI is provided through the nimble package manager:

requires "denim_ui"

Backends

The only usable backend for denim UI is currently the canvas based javascript backend which can be added from nimble:

requires "denim_ui_canvas"

DSL

Denim has a neat custom DSL for writing UIs. It allows one to easily create deep UI trees, as well as group parts of the UI into reusable components.

component MyButton(
  label: string,
  clicked: () -> void
):
  let isHovering = behaviorSubject(false)

  alignment = Alignment.Center

  rectangle(
    color <- isHovering.source.choose(colHotPink, colForestGreen)
  )
  text(
    text <- isHovering.source.choose("hovering", label),
    margin = thickness(10.0),
    color = colWhite
  )

  toggleOnHover(
    isHovering <- not isHovering.value
  )

  onClicked(
    proc(e: Element, args: PointerArgs, res: var EventResult) =
      if not isNil(clicked):
        # NOTE: Due to a quirk in the DSL, function props
        # needs to be surrounded with () before being called
        (clicked)()
  )

proc render*(): Element =
  myButton(
    label = "Hello!",
    clicked = proc() =
      echo "Button clicked"
  )

The Element type

All the visual nodes in the UI tree inherit from the Element type.

panel(), for example, creates an element with the layout semantics of a panel (we'll get to layout soon).

Attributes available on all element types:

  • width: Option[float]
  • height: Option[float]
  • maxWidth: Option[float]
  • minWidth: Option[float]
  • maxHeight: Option[float]
  • minHeight: Option[float]
  • x: Option[float] The elements X-position in its parent
  • y: Option[float] The elements Y-position in its parent
  • xOffset: Option[float] An offset added to the X-position of the element after it has been arranged by its parent
  • yOffset: Option[float] An offset added to the Y-position of the element after it has been arranged by its parent
  • margin: Option[Thickness[float]]
  • alignment: Option[Alignment]
  • visibility: Option[Visibility]
  • clipToBounds: Option[bool]
  • transform: seq[Transform] A list of transforms added in order to the element after it has been arranged by its parent
  • zIndex: Option[int] An index allowing an item to be drawn on top or below of its siblings. The higher the index, the more on top it is drawn.
  • shadow: Option[Shadow]

Note about all the options

Denim exports a converter from any type to Option, so you don't have to explicitly wrap all attribute values in an option: converter toOption*[T](x: T): Option[T] = some[T](x). It has proven quite convenient when writing UI code, but can sometimes get in the way, as generic converters often tend to do. We would like to remove this converter and get the DSL to handle the conversion instead in the future.

Alignment

Alignment = enum
  Stretch, Left, TopLeft, Top, TopRight, Right,
  BottomRight, Bottom, BottomLeft, Center,
  CenterLeft, CenterRight, TopCenter, BottomCenter,
  HorizontalCenter, VerticalCenter

Visibility

Visibility = enum
  Visible, Collapsed, Hidden

Layout

Layout is created using a set of elements that lays out its children in various ways.

Panel

Panel performs the default layout on the children, where all children get all available space. The children of a panel will by default fill their entire space. If a panel contains several children, it simply layers them on top of each other.

panel(width = 100.0, height = 100.0):
  rectangle(color = colRed)
  circle(color = colBlue, radius = 25.0, alignment = Aligmment.Center)

Dock

Lays out its children by docking them to the various sides, one after the other.

dock:
  dockLeft:
    rectangle(width = 10, height = 10, color = colRed)
  dockTop:
    rectangle(width = 10, height = 10, color = colBlue)
  rectangle(width = 10, height = 10, color = colYellow)

The last child element fills the remaining space.

Stack

Stacks its children vertically by default. One can set the direction attribute to stack horizontally.

stack(direction = StackDirection.Horizontal):
  rectangle(width = 10, height = 10, color = colRed)
  rectangle(width = 10, height = 10, color = colBlue)
  rectangle(width = 10, height = 10, color = colYellow)

Grid

Lays out children in a grid formation.

grid(
  width = 100.0,
  height = 100.0,
  rows = @[points(400.0), points(400.0)],
  cols = @[proportion(2.0), proportion(1.0)],
):
  rectangle(color = colRed)
  rectangle(color = colBlue)
  rectangle(color = colYellow)
  rectangle(color = colGreen)

The rows and cols attributes lets one customize the sizes of the rows and columns of the grid. Points lets you explicitly set the size of the row/column, while proportion lets you create row/cols that are proportionally sized relative to each other and the available size.

The children are placed automatically in grid cells, going from top to bottom, left to right.

1 2
3 4

Visual primitives

The folowing element types are used for drawing various shapes and text.

Rectangle

Draws a rectangle with optionally curved edges. It can be filled and/or stroked.

rectangle(
  color = colBlue,
  stroke = colRed,
  strokeWidth = 2.0,
  radius = (5.0, 10.0, 5.0, 10.0),
  width = 50.0,
  height = 20.0
)

Note

radius is used to make the corners of the rectangle curved. It takes a tuple of four float, which assigns a radius to the four corners (topLeft, topRight, bottomRight, bottomLeft)

Circle

Draws a circle with the specified radius. It can be filled and/or stroked.

circle(
  color = colBlue,
  stroke = colRed,
  strokeWidth = 2.0,
  radius = 50.0,
)

Text

text(
  font = "Inter",
  fontSize = 24.0,
  color = colBlack,
  wordWrap = true
)

Setting wordWrap to true enables multiline text. If not, all the text is drawn on one line. It is false by default.

Path

Draws a path using the provided data. The path can be filled and/or stroked. The path data can either be a string as explained here, or a list of PathSegment.

path(
  data = @[moveTo(0.0, 0.0), lineTo(10.0, 0.0), lineTo(5.0, 10.0), close()],
  lineDash = @[4, 2],
  lineCap = LineCap.Round,
  lineJoin = LineJoin.Bevel,
  stroke = colBlue,
  strokeWidth = 2.0
)
alternatively
path(
  data = "M 0 0 L 10 0 L 5 10 Z",
  ...

LineCap

LineCap =  enum
  Square, Butt, Round

LineJoin

LineJoin = enum
  Miter, Bevel, Round

LineDash

Line dash lets one specify a line pattern for the path.

For example

lineDash = @[4, 2]

results in a line like like:

----  ----  ----  ----

Note

If the number of elements in the sequence is odd, the elements of the sequence get copied and concatenated. For example, [5, 15, 25] will become [5, 15, 25, 5, 15, 25]. If the array is empty, the line dash list is cleared and line strokes return to being solid.

Behaviors

Behaviors are used to respond to user input and other events, and can be attached to any alement.

onClicked

Lets one react to an element being clicked (the pointer being pressed and released on the element)

rectangle(width = 50.0, height = 50.0, color = colRed):
  onClicked(
    proc(e: Element, args: PointerArgs, res: var EventResult): void =
      if not res.isHandled:
        echo "We are handling this clicked event"
        res.addHandledBy(e.id)
  )

Note

The EventResult argument is used to keep track of who might already handled this event. This lets us make sure only we handle a clicked event, or if we want to, let other elements (like for example the parent element), also handle the event.

onPressed/onReleased/onPointerMoved

Lets one react to the pointer being pressed/released/moved on the element.

rectangle(width = 50.0, height = 50.0, color = colRed):
  onPressed(
    proc(e: Element, args: PointerArgs, res: var EventResult): void =
      echo "We are pressing"
  )
  onReleased(
    proc(e: Element, args: PointerArgs, res: var EventResult): void =
      echo "We are releasing"
  )
  onPointerMoved(
    proc(e: Element, args: PointerArgs, res: var EventResult): void =
      echo "We are releasing"
  )

onDrag

rectangle(width = 50.0, height = 50.0, color = colRed):
  onDrag(
    proc(amountMoved: Vec2[float]): void =
      echo "We dragged the pointer ", amountMoved
  )

onDrag can take a few more optional arguments: - startedDrag: () -> void - released: () -> void - pointerIndex: PointerIndex = PointerIndex.Primary - canStartDrag: Observable[bool] = behaviorSubject(true).source - dragCaptureThreshold: float = 6.0

onKey

Used to react to key presses.

rectangle(width = 50.0, height = 50.0, color = colRed):
  onKey(
    "Shift",
    proc(e: Element, a: KeyArgs) = echo("Shift was pressed"),
    proc(e: Element, a: KeyArgs) = echo("Shift was released")
  )

Note

Only the focused element receives key events. See focus for more information about focus.

Key bindings

TODO

Observables and Subjects

Denim makes heavy use of ReactiveX-like observables provided by rx-nim. See data binding for more examples of using observables with Denim.

Animation

One can animate any attribute of any element type. It is achieved by binding it to an animated observable. An observable can easily be converted to an animated version by using the animate(source: Observable[T], interpolator: (T,T,float) -> T, duration: float) function.

let widthState = behaviorSubject(10.0)

rectangle(width <- widthState.source.animate(lerp, 250.0), height = 20.0, color = colRed):
  onClicked(
    proc(e: Element, args; PointerArgs, res: var EventResult) =
      widthState <- widthState.value + 10.0
  )

The above code increases the width of the rectangle by 10.0, every time it is clicked. The value is then interpolated over 250.0 ms, using a default linear interpolation function called lerp (which is implemented for a few types, like float and Vec2[float]). The animated value is then data bound to the width attribute.

Focus

One can focus an element to ensure it receivs key events. Focused elements receive keyboard events.

giveFocus(Element)

myElement.giveFocus(
  proc() =
    echo "Element lost focus"
)

Gives focus to myElement. The optional handler argument is called once the element loses focus (which might happen when a different element is given focus).

clearFocus

clearFocus()

Unfocuses any focused element.

releaseFocus(Element)

myElement.releaseFocus()

Releases focus if it is currently held by the supplied by myElement.

hasFocus(Element) -> Observable[bool]

Returns an Observable[bool] that pushes a new value whenever the focused state of the element changes.

myElement.hasFocus().subscribe(
  proc(val: bool) =
    echo "Element focus changed to: ", val
)

isFocused(Element) -> bool

Returns whether the element is currently focused.

  • getCurrentlyFocusedElement() -> Option[Element]
  • focusNext() Assigns focus to the next sibling of the currently focused element (if it has one).

Capturing

Capturing provides a means of making sure an element receives pointer events even if the event occurs outside the elements bounds. This is useful when for example dragging, as the pointer might be dragged outside the element between frames.

capturePointer(Element, [Option[() -> void]])

Captures the pointer to the supplied element. An optional callback can be supplied, which is called when the element loses capture.

releasePointer(Element)

Releases the pointer capture, if it is currently held by the supplied element.

  • hasPointerCapture(Element) -> bool
  • pointerCapturedBySomeoneElse(Element) -> bool

Cursors

There are a few ways of changing the pointer cursor. The simplest is to call the setCursor(Cursor) function.

A better alternative is to use the cursorOnHover(Cursor) or cursorWhilePressed(Cursor, PointerIndex) behaviors.

Three are currently only three cursors implemented, but more will be added.

Cursor = enum
  Default, Clickable, Dragging

Data binding

For dynamic data, we use the Observable pattern, which works pretty much as RX observables (http://reactivex.io/intro.html), sans some missing operators.

We can bind observables to attributes using the <- operator:

let widthValue = behaviorSubject(100.0)
panel(width <- widthValue)

We can also dynamically create a list of child elements using the spread operator ...:

let someChildren = observableCollection(@[panel(), text(), rectangle()])
panel:
  ...someChildren

Note that the spread operator currently works for the following types: - seq[Element] - Subject[Element] - Subject[Option[Element]] - Subject[seq[Element]] - Observable[Element] - CollectionSubject[Element]

More can be supported by simply creating a proc with the following signature:

proc bindChildCollection*(self: Element, items: THE_TYPE_TO_SUPPORT): void =
   ...

This proc should set up the necessary subscriptions that manipulate the elements children using addChild and removeChild.

Here is an example of how the implementation for Subject[Element] works:

proc bindChildCollection*(self: Element, item: Subject[Element]): void =
  var prevElem: Element
  discard item.subscribe(
    proc(e: Element): void =
      if not isNil(prevElem):
        self.removeChild(prevElem)
      prevElem = e
      self.addChild(e)
  )

Components

Components lets us create reusable element types more easily, and can be defined like so:

component ComponentName(prop1: AttrType1, prop2: AttrType2):
  let foo = "bar"

  panel:
    text(text = foo)

Component bodies can contain whatever code (almost) you want, as long as it return an element.

The component can then be used with the DSL syntax like any other element:

panel:
  componentName(prop1 = ....

NOTE: Components should be named with an upper case first letter, but are instantiated with lower case.

Component fields

One can add fields to a component using the field keyword:

component Foo():
  field myField: float = 10.0
  ...

This field can the be accessed as a property on an instance of the component:

let f = Foo()
echo "myField is: ", foo.myField

This is useful if one wants to expose certain parts of a component to the outside world.

Databinding doesn't work for component properties

Since the properties are just passed by value as parameters to the component body, if you want property values of component children to be changed dynamically, they need to be passed as Observables:

component MyDynamicComp(val1: Observabie[float]):
  panel(width <- val1)