An FP game engine for Scala.

UI Components: Custom Components

In this example we define a completely custom component that (for reasons unknown) draws a magenta circle that fits the available space, and then render our component as the only content inside a window, just to prove it works.

How to create a custom component

To make a custom component we need two things:

  1. A data structure representing the state of the component.
  2. A Component instance that explains how our data structure can be used to describe a component.

Defining our component's behaviour by defining its data model

We will create a very simple component that draws a coloured circle in the available space. We infer from the name that we're doing something with a circle, but in terms of data there's only two things we really need:

  1. The colour the circle is going to be. This value won't change.
  2. The bounds of the circle. This value is a more active bit of state that will change as our component's parent is resized and the available space is cascaded down to our component.
final case class ColourfulCircle(colour: RGBA, bounds: Bounds)

Defining our component's behaviour by defining its Component instance

We need to define a Component instance for our ColourfulCircle data structure. This instance will tell Indigo how to:

  1. Calculate the bounds of the component.
  2. Update the model.
  3. Present the component.
  4. Refresh the component.

The astute reader will notice that what we have here is a mini-TEA architecture. That's right, a component is like a mini indigo game all by itself! It has initial state, a pure function to update the state based on the previous state and an event, and a pure function to present the state.

The only things that are perhaps new, are the bounds function and the refresh function. These two functions are essential because the allow the UI system to iterogate the component hierarchy for the bounds of each component in order to do layout calculations, and in turn, to pass new layout information back down to the components.

In this example, the bounds is the stored value and we do no updates in response to events. The interesting functions are present and refresh, where we draw the circle based on the model data, and update the bounds based on the parent, respectively.

object ColourfulCircle:

  given Component[ColourfulCircle, Unit]:
    def bounds(reference: Unit, model: ColourfulCircle): Bounds =
      model.bounds

    def updateModel(
        context: UIContext[Unit],
        model: ColourfulCircle
    ): GlobalEvent => Outcome[ColourfulCircle] =
      case _ =>
        Outcome(model)

    def present(
        context: UIContext[Unit],
        model: ColourfulCircle
    ): Outcome[Layer] =
      Outcome(
        Layer(
          Shape
            .Circle(
              Circle(
                Point.zero,
                if model.bounds.width < model.bounds.height then model.bounds.width / 2
                else model.bounds.height / 2
              ),
              Fill.Color(model.colour)
            )
            .moveTo(context.bounds.center.unsafeToPoint)
        )
      )

    def refresh(
        reference: Unit,
        model: ColourfulCircle,
        parentDimensions: Dimensions
    ): ColourfulCircle =
      model.copy(bounds = model.bounds.resize(parentDimensions))

Using our custom component

With our custom component defined, we can now use it by adding it to a ComponentGroup (or a ComponentList, or even directly to a Window). This works, because the add function will take anything that has a given (AKA implicit) instance of a component, which we made earlier.

def content: ComponentGroup[Unit] =
  ComponentGroup()
    .withBoundsMode(BoundsMode.inherit)
    .withLayout(ComponentLayout.Horizontal())
    .add(ColourfulCircle(RGBA.Magenta, Bounds.zero))