Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

A first plugin

This page builds a picker plugin that adds C-j and C-k to the keys of a picker. C-j selects the next row and C-k the previous one, and both wrap at the ends. The plugin implements the picker-ui world (API), and it needs no Capability.

What you need

  • TinyGo to compile the guest to WebAssembly.
  • wasm-tools to wrap the module as a component.
  • A checkout of the ductus repo, for the wit/ directory.

The dev shell of the repo (nix develop) has both tools.

The guest

//go:build wasm

// Command guest is the first plugin of the docs: the shipped picker plugin plus C-j and C-k.
// It exports ductus:editor/picker.key through a hand-written canonical ABI, with no WASI (A8).
package main

import (
	"unsafe"

	"git.sr.ht/~jcmuller/ductus/plugins/picker/pick"
)

// keyStep gives the row and the action for key. C-j and C-k are new, and every other key does
// what the shipped plugin does.
func keyStep(key string, selected, count, rows uint32) (uint32, string) {
	switch key {
	case "C-j":
		return pick.Step("down", selected, count, rows)
	case "C-k":
		return pick.Step("up", selected, count, rows)
	}

	return pick.Step(key, selected, count, rows)
}

// arena is the memory that cabiRealloc gives the host for the key name.
var arena [64]uint64

// cabiRealloc gives the host 8-byte aligned memory for the key name it passes in.
//
// ponytail: the host never sends a key name longer than 512 bytes, and a longer one traps the
// plugin. Use a growing arena, as plugins/picker/guest does, for a plugin that takes more input.
//
//go:wasmexport cabi_realloc
func cabiRealloc(_ unsafe.Pointer, _, _, size uint32) unsafe.Pointer {
	if size > uint32(len(arena))*8 {
		panic("key name too long")
	}

	return unsafe.Pointer(&arena[0])
}

// abiStep is a step record: the selected row, then the action as a string.
type abiStep struct {
	selected uint32
	action   unsafe.Pointer
	n        uint32
}

var step abiStep

//go:wasmexport ductus:editor/picker@0.1.1#key
func exportKey(key unsafe.Pointer, n, selected, count, rows uint32) unsafe.Pointer {
	at, action := keyStep(unsafe.String((*byte)(key), n), selected, count, rows)
	step = abiStep{at, unsafe.Pointer(unsafe.StringData(action)), uint32(len(action))}

	return unsafe.Pointer(&step)
}

func main() {}

keyStep is the logic: C-j and C-k act as down and up, and every other key goes to the key table of the shipped plugin. The rest is the canonical ABI by hand: dk passes the key name as a string, so the guest hands dk memory for it through cabi_realloc, and answers with a record of the row and the action. The guest imports nothing, so it gets no WASI, and dk would refuse a plugin that does (Capabilities).

Build it

#!/bin/sh
# Build the first plugin into the file named by the first argument.
set -eu

out=$1
dir=$(dirname "$0")
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT

tinygo build -target=wasm-unknown -gc=leaking -no-debug -o "$tmp/core.wasm" "$dir/guest/main.go"
wasm-tools component embed "$dir/../../wit" --world picker-ui "$tmp/core.wasm" -o "$tmp/embed.wasm"
wasm-tools component new "$tmp/embed.wasm" -o "$out"

The script runs the same three steps that go generate runs for the shipped plugins in plugins/. From the root of the repo:

sh plugins/first/build.sh picker.wasm

Load it

Copy the file into plugins/ in the config directory, with the name of the plugin it replaces:

mkdir -p ~/.config/ductus/plugins
cp picker.wasm ~/.config/ductus/plugins/picker.wasm

Start dk, press space f, press i to enter Insert mode, and press C-j. The selection moves down a row. If the plugin does not load, dk shows one warning that names it.

A test in the repo (plugins/first/first_test.go) runs this script and loads the result, so this page stays in step with the code.