Zig NEWS

Cover image for Build a PinePhone App with Zig and zgt
Lup Yuen Lee
Lup Yuen Lee

Posted on • Updated on

Build a PinePhone App with Zig and zgt

Zig is a new-ish Programming Language that works well with C. And it comes with built-in Safety Checks at runtime.

PinePhone is an Arm64 Linux Phone. PinePhone Apps are typically coded in C with GUI Toolkits like GTK.

Can we use Zig to code PinePhone Apps? Maybe make them a little simpler and safer?

Let's find out! Our Zig App shall call the zgt GUI Library, which works with Linux (GTK), Windows and WebAssembly...

zgt is in Active Development, some features may change.

(Please support the zgt project! πŸ™)

Join me as we dive into our Zig App for PinePhone...

PinePhone App with Zig and zgt

Inside The App

Let's create a PinePhone App (pic above) that has 3 Widgets (UI Controls)...

  • Save Button

  • Run Button

  • Editable Text Box

In a while we'll learn to do this in Zig: src/main.zig

What if we're not familiar with Zig?

The following sections assume that we're familiar with C.

The parts that look Zig-ish shall be explained with examples in C.

(If we're keen to learn Zig, see this)

Source Code for our app

(Source)

Import Libraries

We begin by importing the zgt GUI Library and Zig Standard Library into our app: main.zig

// Import the zgt library and Zig Standard Library
const zgt = @import("zgt");
const std = @import("std");
Enter fullscreen mode Exit fullscreen mode

The Zig Standard Library has all kinds of Algos, Data Structures and Definitions.

(More about the Zig Standard Library)

Main Function

Next we define the Main Function for our app: main.zig

/// Main Function for our app
pub fn main() !void {
Enter fullscreen mode Exit fullscreen mode

"!void" is the Return Type for our Main Function...

  • Our Main Function doesn't return any value

    (Hence "void")

  • But our function might return an Error

    (Hence the "!")

Then we initialise the zgt Library and fetch the Window for our app...

  // Init the zgt library
  try zgt.backend.init();

  // Fetch the Window
  var window = try zgt.Window.init();
Enter fullscreen mode Exit fullscreen mode

Why the "try"?

Remember that our Main Function can return an Error.

When "try" detects an Error in the Called Function (like "zgt.backend.init"), it stops the Main Function and returns the Error to the caller.

Let's fill in the Window for our app...

PinePhone App with Zig and zgt

Set the Widgets

Now we populate the Widgets (UI Controls) into our Window: main.zig

  // Set the Window Contents
  try window.set(

    // One Column of Widgets
    zgt.Column(.{}, .{

      // Top Row of Widgets
      zgt.Row(.{}, .{

        // Save Button
        zgt.Button(.{ 
          .label   = "Save", 
          .onclick = buttonClicked 
        }),

        // Run Button
        zgt.Button(.{ 
          .label   = "Run",  
          .onclick = buttonClicked 
        }),
      }),  // End of Row
Enter fullscreen mode Exit fullscreen mode

This code creates a Row of Widgets: Save Button and Run Button.

(We'll talk about buttonClicked in a while)

Next we add an Editable Text Area that will fill up the rest of the Column...

      // Expanded means the widget will take all 
      // the space it can in the parent container (Column)
      zgt.Expanded(

        // Editable Text Area
        zgt.TextArea(.{ 
          .text = "Hello World!\n\nThis is a Zig GUI App...\n\nBuilt for PinePhone...\n\nWith zgt Library!" 
        })

      )  // End of Expanded
    })   // End of Column
  );     // End of Window
Enter fullscreen mode Exit fullscreen mode

What's .{ ... }?

.{ ... } creates a Struct that matches the Struct Type expected by the Called Function (like "zgt.Button").

Thus this code...

// Button with Anonymous Struct
zgt.Button(.{ 
  .label   = "Save", 
  .onclick = buttonClicked 
}),
Enter fullscreen mode Exit fullscreen mode

Is actually the short form of...

// Button with "zgt.Button_Impl.Config"
zgt.Button(zgt.Button_Impl.Config { 
  .label   = "Save", 
  .onclick = buttonClicked 
}),
Enter fullscreen mode Exit fullscreen mode

Because the function zgt.Button expects a Struct of type zgt.Button_Impl.Config.

(Anonymous Struct is the proper name for .{ ... })

Show the Window

We set the Window Size and show the Window: main.zig

  // Resize the Window (might not be correct for PinePhone)
  window.resize(800, 600);

  // Show the Window
  window.show();
Enter fullscreen mode Exit fullscreen mode

Finally we start the Event Loop that will handle Touch Events...

  // Run the Event Loop to handle Touch Events
  zgt.runEventLoop();

}  // End of Main Function
Enter fullscreen mode Exit fullscreen mode

We're done with our Main Function!

Let's talk about the Event Handling for our Buttons...

Handle the Buttons

(Source)

Handle the Buttons

Let's print a message when the Buttons are clicked: main.zig

/// This function is called when the Buttons are clicked
fn buttonClicked(button: *zgt.Button_Impl) !void {

  // Print the Button Label to console
  std.log.info(
    "You clicked button with text {s}",
    .{ button.getLabel() }
  );
}
Enter fullscreen mode Exit fullscreen mode

(*zgt.Button_Impl means "pointer to a Button_Impl Struct")

What's std.log.info?

That's the Zig equivalent of printf for Formatted Printing.

In the Format String, "{s}" is similar to "%s" in C.

(Though we write "{}" for printing numbers)

(More about Format Specifiers)

How is buttonClicked called?

Earlier we did this: main.zig

// Save Button
zgt.Button(.{ 
  .label   = "Save", 
  .onclick = buttonClicked 
}),

// Run Button
zgt.Button(.{ 
  .label   = "Run",  
  .onclick = buttonClicked 
}),
Enter fullscreen mode Exit fullscreen mode

This tells the zgt Library to call buttonClicked when the Save and Run Buttons are clicked.

And that's the complete code for our PinePhone App!

(For comparison, here's a GTK app coded in C)

(Our PinePhone App is based on this zgt demo)

Install Zig Compiler

Let's get ready to build our PinePhone App.

On PinePhone, download the latest Zig Compiler zig-linux-aarch64 from the Zig Compiler Downloads, like so...

## Download the Zig Compiler
curl -O -L https://ziglang.org/builds/zig-linux-aarch64-0.10.0-dev.2674+d980c6a38.tar.xz

## Extract the Zig Compiler
tar xf zig-linux-aarch64-0.10.0-dev.2674+d980c6a38.tar.xz

## Add to PATH. TODO: Also add this line to ~/.bashrc
export PATH="$HOME/zig-linux-aarch64-0.10.0-dev.2674+d980c6a38:$PATH"

## Test the Zig Compiler, should show "0.10.0-dev.2674+d980c6a38"
zig version
Enter fullscreen mode Exit fullscreen mode

It's OK to use SSH to run the above commands remotely on PinePhone.

Or we may use VSCode Remote to run commands and edit source files on PinePhone. (See this)

Zig Compiler on PinePhone

Will Zig Compiler run on any PinePhone?

I tested the Zig Compiler with Manjaro Phosh on PinePhone (pic above).

But it will probably work on any PinePhone distro since the Zig Compiler is a self-contained Arm64 Linux Binary.

(Zig Compiler works with Mobian on PinePhone too)

Install Zigmod

Download the latest Zigmod Package Manager zigmod-aarch64-linux from the Zigmod Releases, like so...

## Download Zigmod Package Manager
curl -O -L https://github.com/nektro/zigmod/releases/download/r80/zigmod-aarch64-linux

## Make it executable
chmod +x zigmod-aarch64-linux 

## Move it to the Zig Compiler directory, rename as zigmod
mv zigmod-aarch64-linux zig-linux-aarch64-0.10.0-dev.2674+d980c6a38/zigmod

## Test Zigmod, should show "zigmod r80 linux aarch64 musl"
zigmod
Enter fullscreen mode Exit fullscreen mode

We'll run Zigmod in the next step to install the dependencies for zgt Library.

Build The App

To build our Zig App on PinePhone...

## Download the Source Code
git clone --recursive https://github.com/lupyuen/zig-pinephone-gui
cd zig-pinephone-gui

## Install the dependencies for zgt library
pushd libs/zgt
zigmod fetch
popd

## Build the app
zig build
Enter fullscreen mode Exit fullscreen mode

(See the Build Log)

If the build fails, check that the "gtk+-3.0" library is installed on PinePhone. (Here's why)

(Our app builds OK on Mobian after installing "gtk+-3.0")

Run The App

To run our Zig App on PinePhone, enter this...

zig-out/bin/zig-pinephone-gui
Enter fullscreen mode Exit fullscreen mode

We should see the screen below.

When we tap the Run and Save Buttons, we should see...

info: You clicked button with text Run
info: You clicked button with text Save
Enter fullscreen mode Exit fullscreen mode

(Because of this)

Yep we have successfully built a Zig App for PinePhone with zgt! πŸŽ‰

PinePhone App with Zig and zgt

Is the app fast and responsive on PinePhone?

Yes our Zig App feels as fast and responsive as a GTK app coded in C.

That's because Zig is a compiled language, and our compiled app calls the GTK Library directly.

Source Code of our Zig App

(Source)

Zig Outcomes

Have we gained anything by coding our app in Zig?

If we compare our Zig App (pic above) with a typical GTK App in C...

Typical GTK App in C

(Source)

Our Zig App looks cleaner and less cluttered, with minimal repetition.

(Hopefully our Zig App is also easier to extend and maintain)

What about Runtime Safety?

Unlike C, Zig automatically does Safety Checks on our app at runtime: Underflow, Overflow, Array Out-of-Bounds, ...

Here's another: Remember that we used "try" to handle Runtime Errors?

// Init the zgt library
try zgt.backend.init();

// Fetch the Window
var window = try zgt.Window.init();
Enter fullscreen mode Exit fullscreen mode

(Source)

Zig Compiler stops us if we forget to handle the errors with "try".

What happens when our Zig App hits a Runtime Error?

Zig shows a helpful Stack Trace...

$ zig-out/bin/zig-pinephone-gui 
Unable to init server: Could not connect: Connection refused
error: InitializationError
zig-pinephone-gui/libs/zgt/src/backends/gtk/backend.zig:25:13: 0x21726e in .zgt.backends.gtk.backend.init (zig-pinephone-gui)
            return BackendError.InitializationError;
            ^
zig-pinephone-gui/src/main.zig:9:5: 0x216b37 in main (zig-pinephone-gui)
    try zgt.backend.init();
    ^
Enter fullscreen mode Exit fullscreen mode

Compare that with a GTK App coded in C...

$ ./a.out
Unable to init server: Could not connect: Connection refused
(a.out:19579): Gtk-WARNING **: 19:17:31.039: cannot open display: 
Enter fullscreen mode Exit fullscreen mode

What about bad pointers?

Zig doesn't validate pointers (like with a Borrow Checker), but it tries to be helpful when it encounters bad pointers...

Anything else we should know about Zig?

Zig Compiler will happily import C Header Files and make them callable from Zig. (Without creating any wrappers)

That's why the zgt GUI Library works so well across multiple GUI platforms: GTK, Win32 AND WebAssembly...

Instead of Makefiles, Zig has a Build System (essentially a tiny custom Zig program) that automates the build steps...

Pinebook Pro

Will the Zig GUI App run on Arm64 laptops like Pinebook Pro?

Yep! The same steps above will work on Pinebook Pro.

Here's our Zig GUI App running with Manjaro Xfce on Pinebook Pro...

Our app running with Manjaro Xfce on Pinebook Pro

What's Next

I hope this article has inspired you to create PinePhone apps in Zig!

Check out the Sample Apps for zgt...

zgt Widgets are explained in the zgt Wiki...

Tips for learning Zig...

Zig works great on Microcontrollers too! Here's what I did...

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/pinephone.md

Notes

  1. This article is the expanded version of this Twitter Thread

  2. Zig works for complex PinePhone Apps too...

    "Mepo: Fast, simple, and hackable OSM map viewer for Linux"

Appendix: Learning Zig

How do we learn Zig?

As of June 2022, Zig hasn't reached version 1.0 so the docs are a little spotty. This is probably the best tutorial for Zig...

After that the Zig Language Reference will be easier to understand...

We need to refer to the Zig Standard Library as well...

Check out the insightful articles at Zig News...

And join the Zig Community on Reddit...

The Gamedev Guide has some helpful articles on Zig...

VSCode Remote on PinePhone

Appendix: VSCode Remote

For convenience, we may use VSCode Remote to edit source files and run commands remotely on PinePhone (pic above)...

Just connect VSCode to PinePhone via SSH, as described here...

In the Remote Session, remember to install the Zig Extension for VSCode...

Appendix: Zig Handles Bad Pointers

How does Zig handle bad pointers?

Zig doesn't validate pointers (like with a Borrow Checker) so it isn't Memory Safe (yet)...

But it tries to be helpful when it encounters bad pointers. Let's do an experiment...

Remember this function from earlier?

/// This function is called when the Buttons are clicked
fn buttonClicked(button: *zgt.Button_Impl) !void {

  // Print the Button Label to console
  std.log.info(
    "You clicked button with text {s}",
    .{ button.getLabel() }
  );
}
Enter fullscreen mode Exit fullscreen mode

(Source)

The above code is potentially unsafe because it dereferences a pointer to a Button...

// `button` is a pointer to a Button Struct
button.getLabel()
Enter fullscreen mode Exit fullscreen mode

Let's hack it by passing a Null Pointer...

// Create a Null Pointer
const bad_ptr = @intToPtr(
  *zgt.Button_Impl,  // Pointer Type
  0                  // Address
);
// Pass the Null Pointer to the function
try buttonClicked(bad_ptr);
Enter fullscreen mode Exit fullscreen mode

(@intToPtr is explained here)

Note that @intToPtr is an Unsafe Builtin Function, we shouldn't call it in normal programs.

When we compile the code above, Zig Compiler helpfully stops us from creating a Null Pointer...

$ zig build
./src/main.zig:8:21: error: pointer type '*.zgt.button.Button_Impl' does not allow address zero
    const bad_ptr = @intToPtr(*zgt.Button_Impl, 0);
Enter fullscreen mode Exit fullscreen mode

Nice! Let's circumvent the best intentions of Zig Compiler and create another Bad Pointer...

// Create a Bad Pointer
const bad_ptr = @intToPtr(
  *zgt.Button_Impl,  // Pointer Type
  0xdeadbee0         // Address
);
// Pass the Bad Pointer to the function
try buttonClicked(bad_ptr);
Enter fullscreen mode Exit fullscreen mode

Zig Compiler no longer stops us. (Remember: @intToPtr is supposed to be unsafe anyway)

When we run it, we get a helpful Stack Trace...

$ zig-out/bin/zig-pinephone-gui 
Segmentation fault at address 0xdeadbee8
zig-pinephone-gui/libs/zgt/src/button.zig:62:9: 0x2184dc in .zgt.button.Button_Impl.getLabel (zig-pinephone-gui)
        if (self.peer) |*peer| {
        ^
zig-pinephone-gui/src/main.zig:56:27: 0x217269 in buttonClicked (zig-pinephone-gui)
        .{ button.getLabel() }
                          ^
zig-pinephone-gui/src/main.zig:9:22: 0x216b0e in main (zig-pinephone-gui)
    try buttonClicked(bad_ptr);
                     ^
zig/lib/std/start.zig:581:37: 0x21e657 in std.start.callMain (zig-pinephone-gui)
            const result = root.main() catch |err| {
                                    ^
zig/lib/std/start.zig:515:12: 0x217a87 in std.start.callMainWithArgs (zig-pinephone-gui)
    return @call(.{ .modifier = .always_inline }, callMain, .{});
           ^
zig/lib/std/start.zig:480:12: 0x217832 in std.start.main (zig-pinephone-gui)
    return @call(.{ .modifier = .always_inline }, callMainWithArgs, .{ @intCast(usize, c_argc), c_argv, envp });
           ^
???:?:?: 0x7f6c902640b2 in ??? (???)
Aborted
Enter fullscreen mode Exit fullscreen mode

Which will be super handy for troubleshooting our Zig App.

Appendix: Zig Build System

How does "zig build" build our Zig App?

Instead of Makefiles, Zig has a Build System (essentially a tiny custom Zig program) that automates the build steps...

When we created our Zig App with...

zig init-exe
Enter fullscreen mode Exit fullscreen mode

It generates this Zig Build Program that will build our Zig App: build.zig

// Zig Build Script. Originally generated by `zig init-exe`
const std = @import("std");

pub fn build(b: *std.build.Builder) void {
    // Standard target options allows the person running `zig build` to choose
    // what target to build for. Here we do not override the defaults, which
    // means any target is allowed, and the default is native. Other options
    // for restricting supported target set are available.
    const target = b.standardTargetOptions(.{});

    // Standard release options allow the person running `zig build` to select
    // between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall.
    const mode = b.standardReleaseOptions();

    const exe = b.addExecutable("zig-pinephone-gui", "src/main.zig");
    exe.setTarget(target);
    exe.setBuildMode(mode);
    exe.install();

    const run_cmd = exe.run();
    run_cmd.step.dependOn(b.getInstallStep());
    if (b.args) |args| {
        run_cmd.addArgs(args);
    }

    const run_step = b.step("run", "Run the app");
    run_step.dependOn(&run_cmd.step);

    const exe_tests = b.addTest("src/main.zig");
    exe_tests.setTarget(target);
    exe_tests.setBuildMode(mode);

    const test_step = b.step("test", "Run unit tests");
    test_step.dependOn(&exe_tests.step);

    // Add zgt library to build
    @import("libs/zgt/build.zig")
        .install(exe, "./libs/zgt")
        catch {};
}
Enter fullscreen mode Exit fullscreen mode

We inserted the last part into the auto-generated code...

    // Add zgt library to build
    @import("libs/zgt/build.zig")
        .install(exe, "./libs/zgt")
        catch {};
Enter fullscreen mode Exit fullscreen mode

To add the zgt GUI Library to our build.

Appendix: GTK Backend for zgt

zgt GUI Library works with GTK, Windows AND WebAssembly. How on earth does it achieve this incredible feat?

Very cleverly! zgt includes multiple GUI Backends, one for each GUI Platform...

Here's the zgt Backend for GTK (as used in our PinePhone App)...

But how does zgt talk to GTK, which is coded in C?

Zig Compiler will happily import C Header Files and make them callable from Zig. (Without creating any wrappers)

This auto-importing of C Header Files works really well, as I have experienced here...

zgt imports the C Header Files for GTK like so: libs/zgt/src/backends/gtk/backend.zig

pub const c = @cImport({
    @cInclude("gtk/gtk.h");
});
Enter fullscreen mode Exit fullscreen mode

(@cImport is explained here)

Then zgt calls the imported GTK Functions like this: backend.zig

pub const Button = struct {
    peer: *c.GtkWidget,

    pub usingnamespace Events(Button);

    fn gtkClicked(peer: *c.GtkWidget, userdata: usize) callconv(.C) void {
        _ = userdata;
        const data = getEventUserData(peer);

        if (data.user.clickHandler) |handler| {
            handler(data.userdata);
        }
    }

    pub fn create() BackendError!Button {

        //  Call gtk_button_new_with_label() from GTK Library
        const button = c.gtk_button_new_with_label("") orelse return error.UnknownError;

        //  Call gtk_widget_show() from GTK Library
        c.gtk_widget_show(button);
        try Button.setupEvents(button);

        //  Call g_signal_connect_data() from GTK Library
        _ = c.g_signal_connect_data(button, "clicked", @ptrCast(c.GCallback, gtkClicked), null, @as(c.GClosureNotify, null), 0);
        return Button{ .peer = button };
    }

    pub fn setLabel(self: *const Button, label: [:0]const u8) void {

        //  Call gtk_button_set_label() from GTK Library
        c.gtk_button_set_label(@ptrCast(*c.GtkButton, self.peer), label);
    }

    pub fn getLabel(self: *const Button) [:0]const u8 {

        //  Call gtk_button_get_label() from GTK Library
        const label = c.gtk_button_get_label(@ptrCast(*c.GtkButton, self.peer));
        return std.mem.span(label);
    }
};
Enter fullscreen mode Exit fullscreen mode

Super Brilliant! πŸ‘

How does zgt link our Zig App with the GTK Library?

zgt uses a Zig Build Program to link the required GUI Libraries with the executable: GTK, Win32, WebAssembly...

PinePhone App with Zig and zgt

Discussion (0)