Module Renderer

calendar/renderer.go

This page is currently completely outdated and awaits a rewrite.

We now need to implement api.ModuleRenderer. This is the interface for rendering our module to the screen. For this part, we use the SDL2 library but we’ll be doing nothing too complex, since the plugin API provides some helper functions for it.


package calendar

import (
	"fmt"
	"time"

	"github.com/QuestScreen/api"
	"github.com/veandco/go-sdl2/sdl"
)

type config struct {
	Font       *api.SelectableFont
	Background *api.SelectableTexturedBackground
}

First, we’ll need a configuration object for our module. Configuration objects hold data which will be configurable on the Web Client’s Configuration page.

Each field of our configuration’s type has to be a pointer to an api.ConfigItem. We will use predefined item types here; if you want to define custom config item types, you will not only need to implement api.ConfigItem, but also provide JavaScript code to the WebClient for editing those item types.

The config item types we’re using are:

Generally, it is advisable to not hard-code colors or fonts into a plugin, so that the user can customize the appearance to match with the configured look of other plugins.


// Renderer implements the rendering of the module's state with SDL.
type Renderer struct {
	config         *config
	curTex, oldTex *sdl.Texture
	mask           *sdl.Texture
	cur            UniversityDate
	oldPos         int32
}

This is our renderer, implementing api.ModuleRenderer. Let’s discuss the data we put in here:


func newRenderer(backend *sdl.Renderer,
	ms api.MessageSender) (api.ModuleRenderer, error) {
	return &Renderer{}, nil
}

These are trivial funcs we need to implement. newRenderer initializes the Renderer; we don’t need to do anything here since everything will be initialized in Rebuild. Consult the diagram in the Documentation for details on the order in which ModuleRenderer funcs are called.


func (r *Renderer) createDateSheet(ctx api.RenderContext,
	d UniversityDate) *sdl.Texture {
	str := fmt.Sprintf("%d %s %d", d.dayOfMonth(), d.month(), d.year())
	face := ctx.Font(
		r.config.Font.FamilyIndex, r.config.Font.Style, r.config.Font.Size)
	strTexture := ctx.TextToTexture(str, face,
		sdl.Color{R: 0, G: 0, B: 0, A: 255})
	_, _, strWidth, strHeight, _ := strTexture.Query()
	bgColor := r.config.Background.Primary.WithAlpha(255)
	canvas := ctx.CreateCanvas(
		strWidth+2*ctx.Unit(), strHeight+2*ctx.Unit(),
		&bgColor, r.mask, api.East|api.South|api.West)
	ctx.Renderer().Copy(strTexture, nil, &sdl.Rect{
		X: 2 * ctx.Unit(), Y: ctx.Unit(), W: strWidth, H: strHeight})
	return canvas.Finish()
}

This is a helper function that renders an image of our date.

Of course, we format the date like a sane person would do: day month year. We query the currently selected font face from the context and render our date string to a texture. This creates a texture that contains our text printed in the given color with transparent background.

Then, we create a canvas based on the dimensions of the rendered text. A canvas redirects all rendering to a texture, which can later be queried with canvas.Finish().

We calculate the inner dimensions with ctx.Unit(), which exists to accomodate for different display sizes and dimensions. This means that our rendered data will occupy the same percentage of width on a FullHD screen as it will on a 4k screen.

CreateCanvas optionally renders a background color and mask on it. We give the selected color and the current mask to it so that it does that for us.

A canvas can have borders, which will extend the canvas’ size from the dimensions we give. The borders are specified via flags. Since we will anchor our date at the top edge of the screen, we create borders for the other three directions.

Now we copy the rendered text with the renderer, offsetting it so that it is centered. To calculate the correct x offset, we need to account for both the inner padding we included in the canvas size, and the outer border the canvas added based on the direction flags.

Finally, we finish the canvas and return the result.


// Rebuild rebuilds the state from the given config and optionally data.
func (r *Renderer) Rebuild(
	ctx api.ExtendedRenderContext, data interface{}, configVal interface{}) {
	r.config = configVal.(*config)
	ctx.UpdateMask(&r.mask, *r.config.Background)
	if data != nil {
		r.cur = data.(UniversityDate)
	}
	if r.curTex != nil {
		r.curTex.Destroy()
	}
	r.curTex = r.createDateSheet(ctx, r.cur)
}

In this function, we need to update the current image based on given configuration value and optionally state data. The Configuration value will always be given, but state data may be nil. If data is not nil, it has been generated by ModuleState’s CreateRendererData.

The API provides us with a function to regenerate the mask based on the current configuration, so we do not need to care about the details.

Since SDL is a C library, it does not automatically delete data. We need to be careful to always destroy SDL objects we do not need anymore before we assign a new value to it.


Before we implement the animation now, let’s discuss how it should look like:

When a new date is set, we want to rip of the old data like we’d do with a calendar sheet. This means that we’ll render the new date at its final position, and over it the old date that falls down and fades away.

This is a pretty easy animation; we only need the images of the old and new date, and update the position and transparency of the old image with each step.


// InitTransition starts transitioning after user input
// changed the state.
func (r *Renderer) InitTransition(
	ctx api.RenderContext, data interface{}) time.Duration {
	r.oldTex = r.curTex
	r.oldTex.SetBlendMode(sdl.BLENDMODE_BLEND)
	r.cur = data.(UniversityDate)
	r.curTex = r.createDateSheet(ctx, r.cur)
	r.oldPos = 0
	return time.Second / 2
}

This func receives the data returned by our endpoint. For the old texture to fade out, we need to activite blending on it. The initial animation state will be the old texture being completely visible and at the original position. We return the time span used for the animation.


// TransitionStep advances the transitioning animation.
func (r *Renderer) TransitionStep(
	ctx api.RenderContext, elapsed time.Duration) {
	pos := api.TransitionCurve{Duration: time.Second / 2}.Cubic(elapsed)
	r.oldTex.SetAlphaMod(uint8((1.0 - pos) * 255))
	_, _, _, oldHeight, _ := r.oldTex.Query()
	r.oldPos = int32(pos * float32(oldHeight) * 3)
}

When advancing the animation, we use a TransitionCurve, which implements a function going from 0.0 at the beginning to 1.0 at the end of the animation. Generally, a linear progression looks very artificial. The Cubic curve we use starts slow, speeds up, and decelerates at the end.

We set the texture’s alpha mod to facilitate fading, and the oldPos defines how far down the old image is. We use the image’s height for defining how far it moves.


// FinishTransition finalizes the transitioning animation.
func (r *Renderer) FinishTransition(ctx api.RenderContext) {
	r.oldTex.Destroy()
	r.oldTex = nil
}

At the end of the animation, we destroy the old texture. We do not need to reset oldPos since that is not used outside of animation.


// Render renders the current state / animation frame.
func (r *Renderer) Render(ctx api.RenderContext) {
	sr := ctx.Renderer()
	screenWidth, _, _ := sr.GetOutputSize()
	_, _, curWidth, curHeight, _ := r.curTex.Query()
	sr.Copy(r.curTex, nil, &sdl.Rect{
		X: screenWidth - curWidth - 5*ctx.Unit(),
		Y: 0, W: curWidth, H: curHeight})
	if r.oldTex != nil {
		_, _, oldWidth, oldHeight, _ := r.oldTex.Query()
		sr.Copy(r.oldTex, nil, &sdl.Rect{
			X: screenWidth - oldWidth - 5*ctx.Unit(),
			Y: r.oldPos, W: oldWidth, H: oldHeight})
	}
}

Finally, rendering. We render the calender to the upper right corner, with a distance of 5 units from the right edge. If r.oldTex is not nil, we’re currently animating so we need to render the old date as well. Fading and position assignment has already been handled by TransitionStep.

This wraps up the code for rendering.