Zig

Please see the up-to-date documentation for the most recent features.

Writing a zellij plugin in Zig should be just as easy as it is in Rust, thanks to zellzig. This guide should walk you through the basics of using it. Some basic knowledge of Zig is assumed.

This guide will get you set up with all the tools you need. We'll be using the zellzig example, which is a simple status bar as a starting point.

Getting Started

First of all, you'll need to install the Zig compiler. Due to Zig being rather early in development, most of the ecosystem, including zellzig, currently revolves around the master version of the compiler. This means that, to install the compiler, you might have to build it yourself.

If you're on Arch Linux, this should be as simple as installing the zig-git AUR package. On other systems, you should check out Zig's guide on building the compiler.

Along with zig, you'll also need gyro. Gyro is a package manager for Zig. It is used by zellzig to manage dependencies.

Creating the project

Now that you've got the necessary tooling up and running, we're ready to actually start coding!

First, create a directory for your plugin, cd into it, and initialize zig and gyro:

mkdir zellzig_plugin
cd zellzig_plugin
zig init-lib
gyro init

Next up, open gyro.zzz and add info about your project and the zellzig dependency:

pkgs:
  zellzig_plugin:
    version: 0.0.0
    description: "My cool zellzig plugin!"
    license: GPL-3.0 # Your license here
    source_url: "https://git.example.com/your_name/zellzig_plugin"
    root: src/main.zig

deps:
  zellzig:
    git:
      url: "https://mzte.de/git/LordMZTE/zellzig.git"
      ref: master
      root: src/main.zig

We'll also need to make some changes on the build.zig build script. Namely,

  • set the target to wasm,
  • set the system API to wasi,
  • switch from a static to a shared libary,
  • and register the gyro dependencies.

This is the diff from the default build.zig file:

const std = @import("std");
+const pkgs = @import("deps.zig").pkgs;

pub fn build(b: *std.build.Builder) void {
    // Standard release options allow the person running `zig build` to select
    // between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall.
    const mode = b.standardReleaseOptions();

-   const lib = b.addStaticLibrary("zellzig_plugin", "src/main.zig");
+   const lib = b.addSharedLibrary("zellzig_plugin", "src/main.zig", .{ .unversioned = {} });
    lib.setBuildMode(mode);
+
+   lib.target.cpu_arch = .wasm32;
+   lib.target.os_tag = .wasi;
+
+   pkgs.addAllTo(lib);
+
    lib.install();

    const main_tests = b.addTest("src/main.zig");
    main_tests.setBuildMode(mode);

    const test_step = b.step("test", "Run library tests");
    test_step.dependOn(&main_tests.step);
}

Now, you might be asking where this deps.zig file is. Well, It'll be generated by gyro in a second.

If you're using git, you should also add a .gitignore:

zig-cache/
zig-out/
.gyro
gyro.lock
deps.zig

Last but not least, we'll also add a zellij layout file for our plugin. Since we're writing a status bar, we'll create a layout that replaces the default status bar with our bar:

---
template:
  direction: Horizontal
  parts:
    - direction: Vertical
      borderless: true
      split_size:
        Fixed: 1
      run:
        plugin:
          location: "zellij:tab-bar"
      
    - direction: Vertical
      plugin: "zig-out/lib/example.wasm"
    
    - direction: Vertical
      borderless: true
      split_size:
        Fixed: 1
      run:
        plugin:
          location: "file:zig-out/lib/zellzig_plugin.wasm"

Writing the plugin

Before we actually get coding, we should check if what we've just created actually is correct. We can test if the default Zig template compiles by running gyro build. This should also download all dependencies.

If everything went well, we should also have the plugin file (which won't work just yet) in zig-out/lib/.

Now that we got all the preparation out the way, we can finally jump in. Let's open src/main.zig!

Zig will have generated some boilerplate in there, but except for the first line, which imports the standard library, we can get rid of everything.

Registering the plugin

We'll now start by importing zellzig, and saving the import in a variable called zz:

const std = @import("std");
const zz = @import("zellzig");

This won't do much on its own, but now we can call zellzig functions. The first one we should call is one that creates all the exported symbols that zellij will call, alongside some communication handling. This function actually needs to be called at compile time, as all it does is give the compiler some functions to be exported by the wasm file. Because of this, It goes in a comptime block.

const std = @import("std");
const zz = @import("zellzig");

comptime {
    zz.createPlugin(@This());
}

We pass in @This(), which is the struct of the current scope. Since namespaces in zig are just structs, this return our root struct. createPlugin needs this, so it can get access to the functions we'll define next.

There are 3 functions that zellzig calls:

  • init
    • called on plugin initialization
  • update
    • called on an event
  • render
    • called to draw the plugin

The init function

In the init function, we should set zellzig's allocator, which it uses for deseralization of zellij's messages.

var gpa = std.heap.GeneralPurposeAllocator(.{}){};

pub fn init() void {
    zz.allocator = gpa.allocator();
}

Here, we create a GeneralPurposeAllocator. It's assigned to a global variable, so it's not dropped once init returns. Then, we assign the allocator to zz.allocator. Now the allocator is set.

Next, we'll make sure zellij doesnt stay open because of our plugin. To do this, we'll make our plugin unselectable:

zz.api.setSelectable(false);

The last thing we'll put in init is a call to subscribe. This tells zellij what events we're interested in:

zz.api.subscribe(&[_]zz.types.EventType{.ModeUpdate}) catch unreachable;

The subscribe function takes a slice of EventTypes we want to receive. Since we're making a status bar, we only care about mode updates, but you can add more if you wish.

This function can return an error, since sending data to zellij might fail. But since we can't recover from it, we'll use catch unreachable, so the program crashes if this fails.

Our code should now look like this:

const std = @import("std");
const zz = @import("zellzig");

comptime {
    zz.createPlugin(@This());
}

var gpa = std.heap.GeneralPurposeAllocator(.{}){};

pub fn init() void {
    zz.allocator = gpa.allocator();
    zz.api.setSelectable(false);
    zz.api.subscribe(&[_]zz.types.EventType{.ModeUpdate}) catch unreachable;
}

The update function

This function is responsible for handling the events we've subscribed to. In our case, that's only ModeUpdates.

First, this is what the function looks like:

pub fn update(ev: zz.Event) void {

}

As a parameter, it takes an event. Since we've only subscribed to ModeUpdate events, that's the only event we'll get.

Since we want to render the current mode, we need to save it when we receive it in update. Thus, we'll create a global variable that holds the current mode, and assign it in update.

var mode: ?zz.types.InputMode = null;

pub fn update(ev: zz.Event) void {
    switch (ev) {
        .ModeUpdate => |mode_info| mode = mode_info.mode,
        else => {},
    }
}

We also make mode optional, since we don't know it once the plugin is initalized, but we'll get a mode event right away, so it doesn't really matter.

The render functio

render is the last function we need. In it, we display the current mode (which we saved in mode, remember?).

pub fn render(rows: i32, cols: i32) void {
    _ = rows;
    _ = cols;

    if (mode) |m| {
        var out = std.io.getStdOut();
        var writer = out.writer();
        writer.writeAll(@tagName(m)) catch {};
    }
}

We assign rows and cols to _, since we don't need them. Next we check if mode is set, and if it is, we print it out.

@tagName returns the string name of an enum variable.

With the last function in place, our plugin should be all done. This is what the finished code looks like:

const std = @import("std");
const zz = @import("zellzig");

comptime {
    zz.createPlugin(@This());
}

var gpa = std.heap.GeneralPurposeAllocator(.{}){};
var mode: ?zz.types.InputMode = null;

pub fn init() void {
    zz.allocator = gpa.allocator();
    zz.api.setSelectable(false);
    zz.api.subscribe(&[_]zz.types.EventType{.ModeUpdate}) catch unreachable;
}

pub fn update(ev: zz.Event) void {
    switch (ev) {
        .ModeUpdate => |mode_info| mode = mode_info.mode,
        else => {},
    }
}

pub fn render(rows: i32, cols: i32) void {
    _ = rows;
    _ = cols;

    if (mode) |m| {
        var out = std.io.getStdOut();
        var writer = out.writer();
        writer.writeAll(@tagName(m)) catch {};
    }
}

Now, all that's left is to build with gyro build -Drelease-fast (you should do a release build, since debug builds tend to be buggy sometimes) and run zellij with our layout file: zellij --layout-path plugin.yaml.