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-toolsto 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.