Zig NEWS

Cover image for (Possibly) LVGL in WebAssembly with Zig Compiler
Lup Yuen Lee
Lup Yuen Lee

Posted on

(Possibly) LVGL in WebAssembly with Zig Compiler

LVGL is a popular Graphics Library for Microcontrollers. (In C)

Zig Compiler works great for compiling C Libraries into WebAssembly. (Based on Clang Compiler)

Can we preview an LVGL App in the Web Browser... With WebAssembly and Zig Compiler? Let's find out!

Why are we doing this?

Right now we're creating a Feature Phone UI (in Zig) for Apache NuttX RTOS (Real-Time Operating System) on Pine64 PinePhone.

Would be awesome if we could prototype the Feature Phone UI in our Web Browser... To make the UI Coding a little easier!

Doesn't LVGL support WebAssembly already?

Today LVGL runs in a Web Browser by compiling with Emscripten and SDL.

Maybe we can do better with newer tools like Zig Compiler? In this article we'll...

  • Run a Zig LVGL App on PinePhone (with NuttX RTOS)

  • Explain how Zig works with WebAssembly (and C Libraries)

  • Compile LVGL Library from C to WebAssembly (with Zig Compiler)

  • Test it with our LVGL App (in Zig)

  • Render Simple LVGL UIs (in Web Browser)

  • Later we might render LVGL UI Controls (with Touch Input)

Maybe someday we'll code and test our LVGL Apps in a Web Browser, thanks to Zig Compiler and WebAssembly!

Mandelbrot Set rendered with Zig and WebAssembly

Mandelbrot Set rendered with Zig and WebAssembly

WebAssembly with Zig

Why Zig? How does it work with WebAssembly?

Zig Programming Language is a Low-Level Systems Language (like C and Rust) that works surprisingly well with WebAssembly.

(And Embedded Devices like PinePhone)

The pic above shows a WebAssembly App that we created with Zig, JavaScript and HTML...

  1. Our Zig Program exports a function that computes the Mandelbrot Set pixels: mandelbrot.zig

    /// Compute the Pixel Color at (px,py) for Mandelbrot Set
    export fn get_pixel_color(px: i32, py: i32) u8 {
      var iterations: u8 = 0;
      var x0 = @intToFloat(f32, px);
      var y0 = @intToFloat(f32, py);
      ...
      while ((xsquare + ysquare < 4.0) and (iterations < MAX_ITER)) : (iterations += 1) {
        tmp = xsquare - ysquare + x0;
        y = 2 * x * y + y0;
        x = tmp;
        xsquare = x * x;
        ysquare = y * y;
      }
      return iterations;
    }
    
  2. Our JavaScript calls the Zig Function above to compute the Mandelbrot Set: game.js

    // Load our WebAssembly Module `mandelbrot.wasm`
    // https://developer.mozilla.org/en-US/docs/WebAssembly/JavaScript_interface/instantiateStreaming
    let Game = await WebAssembly.instantiateStreaming(
      fetch("mandelbrot.wasm"),
      importObject
    );
    ...
    // For every Pixel in our HTML Canvas...
    for (let x = 0; x < canvas.width; x++) {
      for (let y = 0; y < canvas.height; y++) {
    
        // Get the Pixel Color from Zig
        const color = Game.instance.exports
          .get_pixel_color(x, y);
    
        // Render the Pixel in our HTML Canvas
        if      (color < 10)  { context.fillStyle = "red"; }
        else if (color < 128) { context.fillStyle = "grey"; }
        else { context.fillStyle = "white"; }
        context.fillRect(x, y, x + 1, y + 1);
      }
    }
    

    And it renders the pixels in a HTML Canvas.

  3. Our HTML Page defines the HTML Canvas and loads the above JavaScript: demo.html

    <html>
      <body>
        <!-- HTML Canvas for rendering Mandelbrot Set -->
        <canvas id="game_canvas" width="640" height="480"></canvas>
      </body>
      <!-- Load our JavaScript -->
      <script src="game.js"></script>
    </html>
    

That's all we need to create a WebAssembly App with Zig!

(Thanks to sleibrock/zigtoys)

What's mandelbrot.wasm?

mandelbrot.wasm is the WebAssembly Module for our Zig Program, compiled by the Zig Compiler...

## Download and compile the Zig Program for our Mandelbrot Demo
git clone --recursive https://github.com/lupyuen/pinephone-lvgl-zig
cd pinephone-lvgl-zig/demo
zig build-lib \
  mandelbrot.zig \
  -target wasm32-freestanding \
  -dynamic \
  -rdynamic
Enter fullscreen mode Exit fullscreen mode

wasm32-freestanding tells the Zig Compiler to compile our Zig Program into a WebAssembly Module.

(More about this)

How do we run this?

Start a Local Web Server. (Like Web Server for Chrome)

Browse to demo/demo.html. And we'll see the Mandelbrot Set in our Web Browser! (Pic above)

(Try the Mandelbrot Demo)

Zig Calls JavaScript

Can Zig call out to JavaScript?

Yep Zig and JavaScript will happily interoperate both ways!

In our Zig Program, this is how we import a JavaScript Function and call it: mandelbrot.zig

// Import Print Function from JavaScript into Zig
extern fn print(i32) void;

// Print a number to JavaScript Console. Warning: This is slow!
if (iterations == 1) { print(iterations); }
Enter fullscreen mode Exit fullscreen mode

In our JavaScript, we export the print function as we load the WebAssembly Module: game.js

// Export JavaScript Functions to Zig
let importObject = {
  // JavaScript Environment exported to Zig
  env: {
    // JavaScript Print Function exported to Zig
    print: function(x) { console.log(x); }
  }
};

// Load our WebAssembly Module
// and export our Print Function to Zig
let Game = await WebAssembly.instantiateStreaming(
  fetch("mandelbrot.wasm"),  // Load our WebAssembly Module
  importObject               // Export our Print Function to Zig
);
Enter fullscreen mode Exit fullscreen mode

This works OK for printing numbers to the JavaScript Console.

(As explained here)

Will this work for passing Strings and Buffers?

It gets complicated... We need to snoop the WebAssembly Memory.

We'll come back to this when we talk about WebAssembly Logging.

Zig LVGL App on PinePhone with Apache NuttX RTOS

Zig LVGL App on PinePhone with Apache NuttX RTOS

LVGL App in Zig

Will Zig work with LVGL?

Yep we tested an LVGL App in Zig with PinePhone and Apache NuttX RTOS (pic above): lvgltest.zig

/// LVGL App in Zig that renders a Text Label 
fn createWidgetsWrapped() !void {

  // Get the Active Screen
  var screen = try lvgl.getActiveScreen();

  // Create a Label Widget
  var label = try screen.createLabel();

  // Wrap long lines in the label text
  label.setLongMode(c.LV_LABEL_LONG_WRAP);

  // Interpret color codes in the label text
  label.setRecolor(true);

  // Center align the label text
  label.setAlign(c.LV_TEXT_ALIGN_CENTER);

  // Set the label text and colors
  label.setText(
    "#ff0000 HELLO# " ++    // Red Text
    "#00aa00 LVGL ON# " ++  // Green Text
    "#0000ff PINEPHONE!# "  // Blue Text
  );

  // Set the label width
  label.setWidth(200);

  // Align the label to the center of the screen, shift 30 pixels up
  label.alignObject(c.LV_ALIGN_CENTER, 0, -30);
}
Enter fullscreen mode Exit fullscreen mode

(lvgl is our LVGL Wrapper for Zig)

(More about this)

To compile our Zig LVGL App for PinePhone and NuttX RTOS...

## Compile the Zig App `lvgltest.zig`
## for PinePhone (Armv8-A with Cortex-A53)
zig build-obj \
  -target aarch64-freestanding-none \
  -mcpu cortex_a53 \
  -isystem "../nuttx/include" \
  -I "../apps/include" \
  -I "../apps/graphics/lvgl" \
  ... \
  lvgltest.zig

## Copy the Compiled Zig App to NuttX RTOS
## and overwrite `lv_demo_widgets.*.o`
cp lvgltest.o \
  ../apps/graphics/lvgl/lvgl/demos/widgets/lv_demo_widgets.*.o

## Omitted: Link the Compiled Zig App with NuttX RTOS
Enter fullscreen mode Exit fullscreen mode

(See the Complete Command)

(NuttX Build Files)

Zig Compiler produces an Object File lvgltest.o that looks exactly like an ordinary C Object File...

Which links perfectly fine into Apache NuttX RTOS.

And our LVGL Zig App runs OK on PinePhone! (Pic above)

(More about this)

LVGL App in WebAssembly

But will our Zig LVGL App run in a Web Browser with WebAssembly?

Let's find out! We shall...

  1. Compile our Zig LVGL App to WebAssembly

  2. Compile LVGL Library from C to WebAssembly

    (With Zig Compiler)

  3. Render the LVGL Display in JavaScript

Will our Zig LVGL App compile to WebAssembly?

Let's take the earlier steps to compile our Zig LVGL App. To compile for WebAssembly, we change...

  • "zig build-obj" to "zig build-lib"

  • Target becomes "wasm32-freestanding"

  • Add "-dynamic" and "-rdynamic"

  • Remove "-mcpu"

Like this...

## Compile the Zig App `lvglwasm.zig`
## for WebAssembly
zig build-lib \
  -target wasm32-freestanding \
  -dynamic \
  -rdynamic \
  -isystem "../nuttx/include" \
  -I "../apps/include" \
  -I "../apps/graphics/lvgl" \
  ...\
  lvglwasm.zig
Enter fullscreen mode Exit fullscreen mode

(See the Complete Command)

(NuttX Build Files)

And we cloned lvgltest.zig to lvglwasm.zig, because we'll tweak it for WebAssembly.

We removed our Custom Panic Handler, the default one works fine for WebAssembly.

(More about this)

What happens when we run this?

The command above produces the Compiled WebAssembly lvglwasm.wasm.

We start a Local Web Server. (Like Web Server for Chrome)

And browse to our HTML lvglwasm.html

(To load the Compiled WebAssembly)

(To render the LVGL Widgets)

  • That's exported to WebAssembly by our Zig App lvglwasm.zig

(Try the LVGL Demo)

But the WebAssembly won't load in our Web Browser!

Uncaught (in promise) LinkError:
WebAssembly.instantiate():
Import #1 module="env" function="lv_label_create" error:
function import requires a callable
Enter fullscreen mode Exit fullscreen mode

That's because we haven't linked lv_label_create from the LVGL Library.

Let's compile the LVGL Library to WebAssembly...

Compile LVGL to WebAssembly with Zig Compiler

Will Zig Compiler compile C Libraries? Like LVGL?

Yep! This is how we call Zig Compiler to compile lv_label_create and lv_label.c from the LVGL Library...

## Compile LVGL from C to WebAssembly
zig cc \
  -target wasm32-freestanding \
  -dynamic \
  -rdynamic \
  -lc \
  -DFAR= \
  -DLV_MEM_CUSTOM=1 \
  -DLV_FONT_MONTSERRAT_20=1 \
  -DLV_FONT_DEFAULT_MONTSERRAT_20=1 \
  -DLV_USE_LOG=1 \
  -DLV_LOG_LEVEL=LV_LOG_LEVEL_TRACE \
  "-DLV_ASSERT_HANDLER={void lv_assert_handler(void); lv_assert_handler();}" \
  ... \
  lvgl/src/widgets/lv_label.c \
  -o ../../../pinephone-lvgl-zig/lv_label.o
Enter fullscreen mode Exit fullscreen mode

(See the Complete Command)

(NuttX Build Files)

This compiles lv_label.c from C to WebAssembly and generates lv_label.o.

We changed these options...

  • "zig build-lib" becomes "zig cc"

(Because we're compiling C, not Zig)

  • Add "-lc"

(Because we're calling C Standard Library)

  • Add "-DFAR="

(Because we won't need Far Pointers)

  • Add "-DLV_MEM_CUSTOM=1"

(Because we're calling malloc instead of LVGL's TLSF Allocator)

  • Set the Default Font to Montserrat 20...
  -DLV_FONT_MONTSERRAT_20=1 \
  -DLV_FONT_DEFAULT_MONTSERRAT_20=1 \
Enter fullscreen mode Exit fullscreen mode

(Remember to compile LVGL Fonts!)

  • Enable Detailed Logging...
  -DLV_USE_LOG=1 \
  -DLV_LOG_LEVEL=LV_LOG_LEVEL_TRACE \
Enter fullscreen mode Exit fullscreen mode

(We'll come back to this)

  • Handle Assertion Failure...
  "-DLV_ASSERT_HANDLER={void lv_assert_handler(void); lv_assert_handler();} \"
Enter fullscreen mode Exit fullscreen mode

(Like this)

  • Emit the WebAssembly Object File...
  -o ../../../pinephone-lvgl-zig/lv_label.o
Enter fullscreen mode Exit fullscreen mode

This works because Zig Compiler calls Clang Compiler to compile LVGL Library from C to WebAssembly.

So we link lv_label.o with our Zig LVGL App?

Yep we ask Zig Compiler to link the Compiled WebAssembly lv_label.o with our Zig LVGL App lvglwasm.zig...

## Compile the Zig App `lvglwasm.zig` for WebAssembly
## and link with `lv_label.o` from LVGL Library
zig build-lib \
  -target wasm32-freestanding \
  -dynamic \
  -rdynamic \
  -lc \
  -DFAR= \
  -DLV_MEM_CUSTOM=1 \
  -DLV_FONT_MONTSERRAT_20=1 \
  -DLV_FONT_DEFAULT_MONTSERRAT_20=1 \
  -DLV_USE_LOG=1 \
  -DLV_LOG_LEVEL=LV_LOG_LEVEL_TRACE \
  "-DLV_ASSERT_HANDLER={void lv_assert_handler(void); lv_assert_handler();}" \
  ... \
  lvglwasm.zig \
  lv_label.o
Enter fullscreen mode Exit fullscreen mode

(See the Complete Command)

(NuttX Build Files)

When we browse to our HTML lvglwasm.html, we see this in the JavaScript Console...

Uncaught (in promise) LinkError: 
WebAssembly.instantiate(): 
Import #0 module="env" function="lv_obj_clear_flag" error:
function import requires a callable
Enter fullscreen mode Exit fullscreen mode

lv_label_create is no longer missing, because Zig Compiler has linked lv_label.o into our Zig LVGL App.

(Yep Zig Compiler works great for linking WebAssembly Object Files with our Zig App!)

Now we need to compile lv_obj_clear_flag (and the other LVGL Files) from C to WebAssembly...

Compile Entire LVGL Library to WebAssembly

Compile the entire LVGL Library to WebAssembly? Sounds so tedious!

Yeah through sheer tenacity we tracked down lv_obj_clear_flag and all the Missing LVGL Functions called by our Zig LVGL App...

widgets/lv_label.c
core/lv_obj.c
misc/lv_mem.c
core/lv_event.c
core/lv_obj_style.c
core/lv_obj_pos.c
misc/lv_txt.c
draw/lv_draw_label.c
core/lv_obj_draw.c
misc/lv_area.c
core/lv_obj_scroll.c
font/lv_font.c
core/lv_obj_class.c
(Many many more)
Enter fullscreen mode Exit fullscreen mode

(Based on LVGL 8.3.3)

So we wrote a script to compile the above LVGL Source Files from C to WebAssembly: build.sh

## Compile our LVGL Display Driver from C to WebAssembly with Zig Compiler
compile_lvgl ../../../../../pinephone-lvgl-zig/display.c display.o

## Compile LVGL Library from C to WebAssembly with Zig Compiler
compile_lvgl font/lv_font_montserrat_14.c lv_font_montserrat_14.o
compile_lvgl font/lv_font_montserrat_20.c lv_font_montserrat_20.o
compile_lvgl widgets/lv_label.c lv_label.o
compile_lvgl core/lv_obj.c lv_obj.o
compile_lvgl misc/lv_mem.c lv_mem.o
## Many many more
Enter fullscreen mode Exit fullscreen mode

(compile_lvgl is defined here)

(More about display.c later)

And link the Compiled LVGL WebAssemblies with our Zig LVGL App: build.sh

## Compile the Zig App `lvglwasm.zig` for WebAssembly
## and link with LVGL Library compiled for WebAssembly
zig build-lib \
  -target wasm32-freestanding \
  ... \
  lvglwasm.zig \
  display.o \
  lv_font_montserrat_14.o \
  lv_font_montserrat_20.o \
  lv_label.o \
  lv_mem.o \
  ...
Enter fullscreen mode Exit fullscreen mode

We're done with LVGL Library in WebAssembly! (Almost)

Now what happens when we run it?

JavaScript Console says that strlen is missing...

Uncaught (in promise) LinkError: 
WebAssembly.instantiate(): 
Import #0 module="env" function="strlen" error: 
function import requires a callable
Enter fullscreen mode Exit fullscreen mode

Which comes from the C Standard Library. Here's the workaround...

Is it really OK to compile only the necessary LVGL Source Files?

Instead of compiling ALL the LVGL Source Files?

Be careful! We might miss out some Undefined Variables... Zig Compiler blissfully assumes they're at WebAssembly Address 0. And remember to compile the LVGL Fonts!

Thus we really ought to compile ALL the LVGL Source Files.

(Maybe we should disassemble the Compiled WebAssembly and look for other Undefined Variables at WebAssembly Address 0)

LVGL Porting Layer for WebAssembly

Anything else we need for LVGL in WebAssembly?

LVGL expects a millis function that returns the number of Elapsed Milliseconds...

Uncaught (in promise) LinkError: 
WebAssembly.instantiate(): 
Import #0 module="env" function="millis" error: 
function import requires a callable
Enter fullscreen mode Exit fullscreen mode

(Because of this)

We implement millis in Zig: lvglwasm.zig

/// TODO: Return the number of elapsed milliseconds
export fn millis() u32 {
  elapsed_ms += 1;
  return elapsed_ms;
}

/// Number of elapsed milliseconds
var elapsed_ms: u32 = 0;

/// On Assertion Failure, ask Zig to print a Stack Trace and halt
export fn lv_assert_handler() void {
  @panic("*** lv_assert_handler: ASSERTION FAILED");
}

/// Custom Logger for LVGL that writes to JavaScript Console
export fn custom_logger(buf: [*c]const u8) void {
  wasmlog.Console.log("{s}", .{buf});
}
Enter fullscreen mode Exit fullscreen mode

(We should reimplement millis in JavaScript, though it might be slow)

In the code above, we defined lv_assert_handler and custom_logger to handle Assertions and Logging in LVGL.

Let's talk about LVGL Logging...

WebAssembly Logger for LVGL

WebAssembly Logger for LVGL

printf won't work in WebAssembly...

How will we trace the LVGL Execution?

We set the Custom Logger for LVGL, so that we can print Log Messages to the JavaScript Console: lvglwasm.zig

/// Main Function for our Zig LVGL App
pub export fn lv_demo_widgets() void {

  // Set the Custom Logger for LVGL
  c.lv_log_register_print_cb(custom_logger);

  // Init LVGL
  c.lv_init();
Enter fullscreen mode Exit fullscreen mode

("c." refers to functions imported from C to Zig)

custom_logger is defined in our Zig Program: lvglwasm.zig

/// Custom Logger for LVGL that writes to JavaScript Console
export fn custom_logger(buf: [*c]const u8) void {
  wasmlog.Console.log("{s}", .{buf});
}
Enter fullscreen mode Exit fullscreen mode

("[*c]" means C Pointer)

wasmlog is our Zig Logger for WebAssembly: wasmlog.zig

Which calls JavaScript Functions jsConsoleLogWrite and jsConsoleLogFlush to write logs to the JavaScript Console: lvglwasm.js

// Export JavaScript Functions to Zig
const importObject = {
  // JavaScript Functions exported to Zig
  env: {
    // Write to JavaScript Console from Zig
    // https://github.com/daneelsan/zig-wasm-logger/blob/master/script.js
    jsConsoleLogWrite: function(ptr, len) {
      console_log_buffer += wasm.getString(ptr, len);
    },

    // Flush JavaScript Console from Zig
    // https://github.com/daneelsan/zig-wasm-logger/blob/master/script.js
    jsConsoleLogFlush: function() {
      console.log(console_log_buffer);
      console_log_buffer = "";
    },
Enter fullscreen mode Exit fullscreen mode

(Thanks to daneelsan/zig-wasm-logger)

What's wasm.getString?

wasm.getString is our JavaScript Function that reads the WebAssembly Memory into a JavaScript Array: lvglwasm.js

// WebAssembly Helper Functions in JavaScript
const wasm = {
  // WebAssembly Instance
  instance: undefined,

  // Init the WebAssembly Instance
  init: function (obj) {
    this.instance = obj.instance;
  },

  // Fetch the Zig String from a WebAssembly Pointer
  getString: function (ptr, len) {
    const memory = this.instance.exports.memory;
    const text_decoder = new TextDecoder();
    return text_decoder.decode(
      new Uint8Array(memory.buffer, ptr, len)
    );
  },
};
Enter fullscreen mode Exit fullscreen mode

(TextDecoder converts bytes to text)

(Remember earlier we spoke about snooping WebAssembly Memory with a WebAssembly Pointer? This is how we do it)

Now we can see the LVGL Log Messages in the JavaScript Console yay! (Pic above)

[Warn] lv_disp_get_scr_act:
no display registered to get its active screen
(in lv_disp.c line #54)
Enter fullscreen mode Exit fullscreen mode

Let's initialise the LVGL Display...

Initialise LVGL Display

What happens when LVGL runs?

According to the LVGL Docs, this is how we initialise and operate LVGL...

  1. Call lv_init

  2. Register the LVGL Display (and Input Devices)

  3. Call lv_tick_inc(x) every x milliseconds (in an Interrupt) to report the Elapsed Time to LVGL

    (Not required, because LVGL calls millis to fetch the Elapsed Time)

  4. Call lv_timer_handler every few milliseconds to handle LVGL Tasks

To register the LVGL Display, we follow these steps...

Easy peasy for Zig right?

Sadly we can't do it in Zig...

// Nope, can't allocate LVGL Display Driver in Zig!
// `lv_disp_drv_t` is an Opaque Type

var disp_drv = c.lv_disp_drv_t{};
c.lv_disp_drv_init(&disp_drv);
Enter fullscreen mode Exit fullscreen mode

Because LVGL Display Driver lv_disp_drv_t is an Opaque Type.

(Same for the LVGL Draw Buffer lv_disp_draw_buf_t)

What's an Opaque Type in Zig?

When we import a C Struct into Zig and it contains Bit Fields...

Zig Compiler won't let us access the fields of the C Struct. And we can't allocate the C Struct either.

lv_disp_drv_t contains Bit Fields, hence it's an Opaque Type and inaccessible in Zig. (See this)

Bummer. How to fix Opaque Types in Zig?

Our workaround is to write C Functions to allocate and initialise the Opaque Types...

Which gives us this LVGL Display Interface for Zig: display.c

Finally with the workaround, here's how we initialise the LVGL Display in Zig: lvglwasm.zig

/// Main Function for our Zig LVGL App
pub export fn lv_demo_widgets() void {

  // Create the Memory Allocator for malloc
  memory_allocator = std.heap.FixedBufferAllocator
    .init(&memory_buffer);

  // Set the Custom Logger for LVGL
  c.lv_log_register_print_cb(custom_logger);

  // Init LVGL
  c.lv_init();

  // Fetch pointers to Display Driver and Display Buffer,
  // exported by our C Functions
  const disp_drv = c.get_disp_drv();
  const disp_buf = c.get_disp_buf();

  // Init Display Buffer and Display Driver as pointers,
  // by calling our C Functions
  c.init_disp_buf(disp_buf);
  c.init_disp_drv(
    disp_drv,  // Display Driver
    disp_buf,  // Display Buffer
    flushDisplay,  // Callback Function to Flush Display
    720,  // Horizontal Resolution
    1280  // Vertical Resolution
  );

  // Register the Display Driver as a pointer
  const disp = c.lv_disp_drv_register(disp_drv);

  // Create the widgets for display
  createWidgetsWrapped() catch |e| {
    // In case of error, quit
    std.log.err("createWidgetsWrapped failed: {}", .{e});
    return;
  };

  // Up Next: Handle LVGL Tasks
Enter fullscreen mode Exit fullscreen mode

(memory_allocator is explained here)

Now we handle LVGL Tasks...

Handle LVGL Tasks

Earlier we talked about handling LVGL Tasks...

  1. Call lv_tick_inc(x) every x milliseconds (in an Interrupt) to report the Elapsed Time to LVGL

    (Not required, because LVGL calls millis to fetch the Elapsed Time)

  2. Call lv_timer_handler every few milliseconds to handle LVGL Tasks

(From the LVGL Docs)

This is how we call lv_timer_handler in Zig: lvglwasm.zig

/// Main Function for our Zig LVGL App
pub export fn lv_demo_widgets() void {

  // Omitted: Init LVGL Display

  // Create the widgets for display
  createWidgetsWrapped() catch |e| {
    // In case of error, quit
    std.log.err("createWidgetsWrapped failed: {}", .{e});
    return;
  };

  // Handle LVGL Tasks
  // TODO: Call this from Web Browser JavaScript,
  // so that our Web Browser won't block
  var i: usize = 0;
  while (i < 5) : (i += 1) {
    _ = c.lv_timer_handler();
  }
Enter fullscreen mode Exit fullscreen mode

We're ready to render the LVGL Display in our HTML Page!

Something doesn't look right...

Yeah we should have called lv_timer_handler from our JavaScript.

(Triggered by a JavaScript Timer or requestAnimationFrame)

But for our quick demo, this will do. For now!

Render LVGL Display in WebAssembly

Render LVGL Display in Zig

Finally we render our LVGL Display in the Web Browser... Spanning C, Zig and JavaScript! (Pic above)

Earlier we saw this LVGL Initialisation in our Zig App: lvglwasm.zig

// Init LVGL
c.lv_init();

// Fetch pointers to Display Driver and Display Buffer,
// exported by our C Functions
const disp_drv = c.get_disp_drv();
const disp_buf = c.get_disp_buf();

// Init Display Buffer and Display Driver as pointers,
// by calling our C Functions
c.init_disp_buf(disp_buf);
c.init_disp_drv(
  disp_drv,  // Display Driver
  disp_buf,  // Display Buffer
  flushDisplay,  // Callback Function to Flush Display
  720,  // Horizontal Resolution
  1280  // Vertical Resolution
);
Enter fullscreen mode Exit fullscreen mode

What's inside init_disp_buf?

init_disp_buf tells LVGL to render the display pixels to our LVGL Canvas Buffer: display.c

// Init the LVGL Display Buffer in C, because Zig
// can't access the fields of the Opaque Type
void init_disp_buf(lv_disp_draw_buf_t *disp_buf) {
  lv_disp_draw_buf_init(
    disp_buf,       // LVGL Display Buffer
    canvas_buffer,  // Render the pixels to our LVGL Canvas Buffer
    NULL,           // No Secondary Buffer
    BUFFER_SIZE     // Buffer the entire display (720 x 1280 pixels)
  );
}
Enter fullscreen mode Exit fullscreen mode

(canvas_buffer is defined here)

Then our Zig App initialises the LVGL Display Driver: lvglwasm.zig

// Init Display Driver as pointer,
// by calling our C Function
c.init_disp_drv(
  disp_drv,  // Display Driver
  disp_buf,  // Display Buffer
  flushDisplay,  // Callback Function to Flush Display
  720,  // Horizontal Resolution
  1280  // Vertical Resolution
);
Enter fullscreen mode Exit fullscreen mode

(init_disp_drv is defined here)

This tells LVGL to call flushDisplay (in Zig) when the LVGL Display Canvas is ready to be rendered: lvglwasm.zig

/// LVGL calls this Callback Function to flush our display
export fn flushDisplay(
  disp_drv: ?*c.lv_disp_drv_t,      // LVGL Display Driver
  area:     [*c]const c.lv_area_t,  // LVGL Display Area
  color_p:  [*c]c.lv_color_t        // LVGL Display Buffer
) void {

  // Call the Web Browser JavaScript
  // to render the LVGL Canvas Buffer
  render();

  // Notify LVGL that the display has been flushed.
  // Remember to call `lv_disp_flush_ready`
  // or Web Browser will hang on reload!
  c.lv_disp_flush_ready(disp_drv);
}
Enter fullscreen mode Exit fullscreen mode

flushDisplay (in Zig) calls render (in JavaScript) to render the LVGL Display Canvas.

We bubble up from Zig to JavaScript...

Zig LVGL App rendered in Web Browser with WebAssembly

Zig LVGL App rendered in Web Browser with WebAssembly

Render LVGL Display in JavaScript

Phew OK. What happens in our JavaScript?

Earlier we saw that flushDisplay (in Zig) calls render (in JavaScript) to render the LVGL Display Canvas.

render (in JavaScript) draws the LVGL Canvas Buffer to our HTML Canvas: lvglwasm.js

// Render the LVGL Canvas from Zig to HTML
// https://github.com/daneelsan/minimal-zig-wasm-canvas/blob/master/script.js
render: function() {  // TODO: Add width and height

  // Get the WebAssembly Pointer to the LVGL Canvas Buffer
  const bufferOffset = wasm.instance.exports.getCanvasBuffer();

  // Load the WebAssembly Pointer into a JavaScript Image Data
  const memory = wasm.instance.exports.memory;
  const ptr = bufferOffset;
  const len = (canvas.width * canvas.height) * 4;
  const imageDataArray = new Uint8Array(memory.buffer, ptr, len)
  imageData.data.set(imageDataArray);

  // Render the Image Data to the HTML Canvas
  context.clearRect(0, 0, canvas.width, canvas.height);
  context.putImageData(imageData, 0, 0);
}
Enter fullscreen mode Exit fullscreen mode

(imageData and context are defined here)

How does it fetch the LVGL Canvas Buffer?

The JavaScript above calls getCanvasBuffer (in Zig) and get_canvas_buffer (in C) to fetch the LVGL Canvas Buffer: display.c

// Canvas Buffer for rendering LVGL Display
// TODO: Swap the RGB Bytes in LVGL, the colours are inverted for HTML Canvas
#define HOR_RES     720      // Horizontal Resolution
#define VER_RES     1280     // Vertical Resolution
#define BUFFER_ROWS VER_RES  // Number of rows to buffer
#define BUFFER_SIZE (HOR_RES * BUFFER_ROWS)
static lv_color_t canvas_buffer[BUFFER_SIZE];

// Return a pointer to the LVGL Canvas Buffer
lv_color_t *get_canvas_buffer(void) {
  return canvas_buffer;
}
Enter fullscreen mode Exit fullscreen mode

And the LVGL Display renders OK in our HTML Canvas yay! (Pic above)

(Try the LVGL Demo)

(See the JavaScript Log)

(Thanks to daneelsan/minimal-zig-wasm-canvas)

What's Next

Up Next: Feature Phone UI for PinePhone! To make our Feature Phone clickable, we'll pass Mouse Events from JavaScript to LVGL.

(Through an LVGL Input Device Driver)

We'll experiment with Live Reloading: Whenever we save our Zig LVGL App, it auto-recompiles and auto-reloads the WebAssembly HTML.

Which makes UI Prototyping a lot quicker in LVGL. Stay Tuned for updates!

Meanwhile please check out the other articles on NuttX for PinePhone...

Many Thanks to my GitHub Sponsors for supporting my work! This article wouldn't have been possible without your support.

Got a question, comment or suggestion? Create an Issue or submit a Pull Request here...

lupyuen.github.io/src/lvgl3.md

Appendix: C Standard Library is Missing

strlen is missing from our Zig WebAssembly...

But strlen should come from the C Standard Library! (musl)

Not sure why strlen is missing, but we fixed it (temporarily) by copying from the Zig Library Source Code: lvglwasm.zig

// C Standard Library from zig-macos-x86_64-0.10.0-dev.2351+b64a1d5ab/lib/zig/c.zig
export fn strlen(s: [*:0]const u8) callconv(.C) usize {
  return std.mem.len(s);
}

// Also memset, memcpy, strcpy...
Enter fullscreen mode Exit fullscreen mode

(Maybe because we didn't export strlen in our Zig Main Program lvglwasm.zig?)

What if we change the target to wasm32-freestanding-musl?

Nope doesn't help, same problem.

What if we use "zig build-exe" instead of "zig build-lib"?

Sorry "zig build-exe" is meant for building WASI Executables. (See this)

"zig build-exe" is not supposed to work for WebAssembly in the Web Browser. (See this)

Appendix: LVGL Memory Allocation

What happens if we omit "-DLV_MEM_CUSTOM=1"?

By default, LVGL uses the Two-Level Segregate Fit (TLSF) Allocator for Heap Memory.

But TLSF Allocator fails inside block_next...

main: start
loop: start
lv_demo_widgets: start
before lv_init
[Info]  lv_init: begin  (in lv_obj.c line #102)
[Trace] lv_mem_alloc: allocating 76 bytes   (in lv_mem.c line #127)
[Trace] lv_mem_alloc: allocated at 0x1a700  (in lv_mem.c line #160)
[Trace] lv_mem_alloc: allocating 28 bytes   (in lv_mem.c line #127)
[Trace] lv_mem_alloc: allocated at 0x1a750  (in lv_mem.c line #160)
[Warn]  lv_init: Log level is set to 'Trace' which makes LVGL much slower   (in lv_obj.c line #176)
[Trace] lv_mem_realloc: reallocating 0x14 with 8 size   (in lv_mem.c line #196)
[Error] block_next: Asserted at expression: !block_is_last(block)   (in lv_tlsf.c line #459)

004a5b4a:0x29ab2 Uncaught (in promise) RuntimeError: unreachable
  at std.builtin.default_panic (004a5b4a:0x29ab2)
  at lv_assert_handler (004a5b4a:0x2ac6c)
  at block_next (004a5b4a:0xd5b3)
  at lv_tlsf_realloc (004a5b4a:0xe226)
  at lv_mem_realloc (004a5b4a:0x20f1)
  at lv_layout_register (004a5b4a:0x75d8)
  at lv_flex_init (004a5b4a:0x16afe)
  at lv_extra_init (004a5b4a:0x16ae5)
  at lv_init (004a5b4a:0x3f28)
  at lv_demo_widgets (004a5b4a:0x29bb9)
Enter fullscreen mode Exit fullscreen mode

Thus we set "-DLV_MEM_CUSTOM=1" to call malloc instead of LVGL's TLSF Allocator.

(block_next calls offset_to_block, which calls tlsf_cast. Maybe the Pointer Cast doesn't work for Clang WebAssembly?)

But Zig doesn't support malloc for WebAssembly!

We call Zig's FixedBufferAllocator to implement malloc: lvglwasm.zig

/// Main Function for our Zig LVGL App
pub export fn lv_demo_widgets() void {

  // Create the Memory Allocator for malloc
  memory_allocator = std.heap.FixedBufferAllocator
    .init(&memory_buffer);
Enter fullscreen mode Exit fullscreen mode

Here's our (incomplete) implementation of malloc: lvglwasm.zig

/// Zig replacement for malloc
export fn malloc(size: usize) ?*anyopaque {
  // TODO: Save the slice length
  const mem = memory_allocator.allocator().alloc(u8, size) catch {
    @panic("*** malloc error: out of memory");
  };
  return mem.ptr;
}

/// Zig replacement for realloc
export fn realloc(old_mem: [*c]u8, size: usize) ?*anyopaque {
  // TODO: Call realloc instead
  const mem = memory_allocator.allocator().alloc(u8, size) catch {
    @panic("*** realloc error: out of memory");
  };
  _ = memcpy(mem.ptr, old_mem, size);
  if (old_mem != null) {
    // TODO: How to free without the slice length?
    // memory_allocator.allocator().free(old_mem[0..???]);
  }
  return mem.ptr;
}

/// Zig replacement for free
export fn free(mem: [*c]u8) void {
  if (mem == null) {
    @panic("*** free error: pointer is null");
  }
  // TODO: How to free without the slice length?
  // memory_allocator.allocator().free(mem[0..???]);
}

/// Memory Allocator for malloc
var memory_allocator: std.heap.FixedBufferAllocator = undefined;

/// Memory Buffer for malloc
var memory_buffer = std.mem.zeroes([1024 * 1024]u8);
Enter fullscreen mode Exit fullscreen mode

(Remember to copy the old memory in realloc!)

(If we ever remove "-DLV_MEM_CUSTOM=1", remember to set "-DLV_MEM_SIZE=1000000")

Appendix: LVGL Fonts

Remember to compile the LVGL Fonts! Or our LVGL Text Label won't be rendered...

## Compile LVGL Fonts from C to WebAssembly with Zig Compiler
compile_lvgl font/lv_font_montserrat_14.c lv_font_montserrat_14
compile_lvgl font/lv_font_montserrat_20.c lv_font_montserrat_20

## Compile the Zig LVGL App for WebAssembly 
## and link with LVGL Fonts
zig build-lib \
  -DLV_FONT_MONTSERRAT_14=1 \
  -DLV_FONT_MONTSERRAT_20=1 \
  -DLV_FONT_DEFAULT_MONTSERRAT_20=1 \
  -DLV_USE_FONT_PLACEHOLDER=1 \
  ...
  lv_font_montserrat_14.o \
  lv_font_montserrat_20.o \
Enter fullscreen mode Exit fullscreen mode

(Source)

Appendix: LVGL Screen Not Found

Why does LVGL say "No Screen Found" in lv_obj_get_disp?

[Info]  lv_init: begin  (in lv_obj.c line #102)
[Trace] lv_init: finished   (in lv_obj.c line #183)
before lv_disp_drv_register
[Warn]  lv_obj_get_disp: No screen found    (in lv_obj_tree.c line #290)
[Info]  lv_obj_create: begin    (in lv_obj.c line #206)
[Trace] lv_obj_class_create_obj: Creating object with 0x12014 class on 0 parent     (in lv_obj_class.c line #45)
[Warn]  lv_obj_get_disp: No screen found    (in lv_obj_tree.c line #290)
Enter fullscreen mode Exit fullscreen mode

(See the Complete Log)

That's because the Display Linked List _lv_disp_ll is allocated by LV_ITERATE_ROOTS in _lv_gc_clear_roots...

And we forgot to compile _lv_gc_clear_roots in lv_gc.c. Duh!

(Zig Compiler assumes that Undefined Variables like _lv_disp_ll are at WebAssembly Address 0)

After compiling _lv_gc_clear_roots and lv_gc.c, the "No Screen Found" error no longer appears.

(Maybe we should disassemble the Compiled WebAssembly and look for other Undefined Variables at WebAssembly Address 0)

TODO: For easier debugging, how to disassemble Compiled WebAssembly with cross-reference to Source Code? Similar to "objdump --source"? Maybe with wabt or binaryen?

Top comments (0)