rxx embeds a Rune scripting engine that lets you extend the editor with plugins. Plugins are scripts that can draw overlays, register custom commands, extend GPU pipelines, and react to user input. This guide walks through the core ideas; the API reference has the full surface.
A plugin is a <name>.rune file in the plugin directory, or
a <name>/<name>.rune package whose directory carries
its assets (WGSL shaders, PNG icons). Search order is repeated
--plugin-dir <path> arguments, the PATH-style
RXX_PLUGIN_PATH list, local plugins/, then the OS
config directory's plugins/. The first plugin found for each
name wins. Plugins load in lexicographic name order and hot-reload when
their script changes. See plugin discovery.
Every plugin exports pub fn init(rx). It runs once at load and
returns the plugin's state value — usually a map
(#{ ... }). That value is handed back as the first argument of
every other hook, command handler, and export, so plugin state is explicit
rather than global.
A script error or GPU validation error disables only the offending plugin and posts one message — other plugins keep running, and the frame survives. During development, watch the message line.
Let's start with a small plugin: a classic vim-style
'current mode' indicator. It defines two hooks — init to
set up state, and draw to paint the overlay each frame.
// mode-vis.rune - current mode indicator
const TEXT_X = 10.0;
const BOTTOM_MARGIN = 46.0;
const GLYPH_HEIGHT = 14.0;
pub fn init(rx) {
// no state needed; return an empty map
#{}
}
pub fn draw(state, rx) {
let (_, height) = rx.screen_size();
let text_y = (height as f64) - BOTTOM_MARGIN - GLYPH_HEIGHT;
let mode = rx.mode();
let label = `-- ${mode.to_uppercase()} --`;
if mode == "normal" {
rx.draw_text(label, TEXT_X, text_y, rx::rgb(255, 255, 255));
} else if mode.starts_with("visual") {
rx.draw_text(label, TEXT_X, text_y, rx::rgb(0xff, 0x33, 0x66));
} else if mode == "command" {
// command mode is skipped
} else {
// script and other modes
rx.draw_text(label, TEXT_X, text_y, rx::rgb(0xff, 0x33, 0x66));
}
}
The draw(state, rx) hook is called every frame, on the UI tier
— this is the only place rx.draw_text and
rx.draw_line and
rx.draw_rect are live. It reads the current mode with
rx.mode(), formats a label, and paints it at a fixed position
in a color that depends on the mode. UI coordinates are y-down, origin
top-left.
From init you register commands the user can invoke from the
command line. rx.register_command(name, sig, help, handler)
takes a name, a typed parameter signature, a help string, and a handler
function. The handler is called as handler(state, rx, args),
where args holds the parsed, typed arguments.
pub fn init(rx) {
rx.register_command("greet", ["str"], "Say hello", greet);
#{ count: 0 }
}
pub fn greet(state, rx, args) {
state.count += 1;
rx.message(`hello ${args[0]} (${state.count})`);
}
Running :greet world posts hello world (1) to the
message line, then (2), and so on — the count lives in the
plugin state that init returned. The signature is a list of
type tags: "int", "float", "str",
"color", "bool", with a trailing ? for
an optional parameter. Use register_command_repeating for a
command that repeats while its bound key is held.
Plugins react to view lifecycle, frame updates, and mouse input through
optional hooks — define a pub fn with the right name and
signature and rxx calls it.
view_added(state, rx, id) and
view_removed(state, rx, id) fire when the user opens or closes a
view. Use them to keep per-view state in sync — e.g. drop an entry from
a map keyed by view id so you don't hold stale references.
mouse_input(state, rx, button, input) delivers button events:
button is "left", "right", or
"middle"; input is "pressed" or
"released". The hook carries no coordinates — read the
cursor with rx.cursor() (session coordinates), or track it from
cursor_moved(state, rx, x, y), which receives window-logical
coordinates (rx.session_coords(x, y) converts them).
This example assumes init returns #{ mx: 0.0, my: 0.0 }.
pub fn cursor_moved(state, rx, x, y) {
let (sx, sy) = rx.session_coords(x, y);
state.mx = sx;
state.my = sy;
}
pub fn mouse_input(state, rx, button, input) {
if button != "left" || input != "pressed" {
return;
}
// hit-test against state.mx / state.my, then act
}
To build a tool that takes over the canvas — rotate a selection by
dragging, say — enter a script mode. Any name passed to
rx.switch_mode(name) that isn't a builtin enters a script mode:
builtin input handling goes inert, escape exits, and your plugin drives
everything through its hooks and bindings.
The command line is not reachable from a script mode, so you bind
keys to your commands in init with rx.bind(mode, mapping)
using :map syntax. The switch_mode(state, rx) hook
fires on mode edges — query rx.mode() (and
rx.prev_mode()) to detect leaving your mode and clean up.
const TOOL_MODE = "visual (my-tool)";
pub fn init(rx) {
rx.register_command("my-tool", [], "Enter the tool", enter);
rx.register_command("my-tool/done", [], "Leave the tool", done);
rx.bind(TOOL_MODE, "<return> :my-tool/done");
#{ in_progress: false }
}
pub fn enter(state, rx, args) {
state.in_progress = true;
rx.switch_mode(TOOL_MODE);
}
pub fn done(state, rx, args) {
rx.switch_mode("normal");
}
pub fn switch_mode(state, rx) {
if rx.mode() != TOOL_MODE {
state.in_progress = false;
}
}
Input hooks still run for enabled plugins, so guard your tool's handlers
with rx.mode() == TOOL_MODE. For a rotation tool, track a drag
and preview the result in a staging texture from shade.
Applying the result writes the artwork and records an undoable edit.
See the rotate-scale plugin for a
complete implementation.
Use shade(state, rx, encoder)
to render into textures or run compute passes before screen composition.
Use render(state, rx, pass)
to draw on the screen. Floating panels use
overlay(state, rx, pass),
which runs after all ordinary render hooks, above editor UI and plugin previews.
The rx, encoder, and pass handles belong to their hook call;
keep persistent textures in state, and acquire fresh editor handles each frame.
pass.draw_sprite(source, options)
supplies the shader, pipeline, bindings, and geometry for ordinary textured
drawing. The source can be a script texture or texture view. Only dst
is required; src defaults to the whole texture. Optional
transform, color, opacity, and
blend control placement, tint, transparency, and blending.
Coordinates start at the top-left and increase downward; sampling uses nearest neighbors.
pub fn init(rx) {
let icon = rx.create_texture(1, 1).unwrap();
icon.fill(rx::rgb(255, 80, 120));
#{ icon }
}
pub fn render(state, rx, pass) {
pass.draw_sprite(state.icon, #{
dst: rx::rect(10.0, 10.0, 26.0, 26.0),
opacity: 0.9,
}).unwrap();
}
For an image asset, rx.read_png(path)
returns a texture from a plugin-relative path. The pass supplies the target
dimensions and format automatically. When mixing sprite and custom drawing,
rebind your custom pipeline, bind groups, and vertex buffer after draw_sprite.
Open a staging pass for an uncommitted preview; staging contents are cleared every frame. This example uses the texture created above and paints a preview over the active view.
pub fn shade(state, rx, encoder) {
let id = rx.active_view_id();
let pass = encoder.begin_staging_pass("preview", id, "load").unwrap();
pass.draw_sprite(state.icon, #{
dst: rx::rect(0.0, 0.0, 8.0, 8.0),
}).unwrap();
pass.end();
}
To commit artwork, queue the operation in a command handler, draw it in a
view pass, and call
rx.touch_view(id)
to record the edit for undo. To erase pixels, draw a transparent texture with
blend: "replace" into a pass opened with "load".
Default alpha blending leaves the destination unchanged for transparent source pixels.
In shade, obtain artwork with
encoder.view_layer(id).
It can be sampled during the same frame's render and overlay
hooks; acquire it again next frame. It contains the full sheet, including layer
strips. Crop src to select a frame and layer. To compose the visible
artwork, use rx.layer_visibility(id)
and rx.layer_opacity(id),
drawing layers from bottom to top. ViewInfo
includes frame dimensions, the current animation frame, and animation_preview_visible.
Draw a floating panel in overlay. Implement
capture_mouse(state, rx, button, input) -> bool
to consume button events owned by the panel. Capture runs in reverse plugin
load order, before ordinary mouse hooks and editor handling. Return true
for both press and release of a panel-started gesture, including releases outside
its bounds; return false for other events. Motion, wheel, and keyboard
events are not captured by this hook. The miniview plugin
combines live artwork, overlay drawing, dragging, resizing, and target selection.
For custom shaders, use
rx.create_render_pipeline(shader, options).
The descriptor requires vertex, fragment, and blend;
it also controls texture groups, topology, vertex layout, and target format.
rx.create_sprite_vertices(texture, options)
builds geometry for that lower-level API; set the pipeline, bind groups, and vertex
buffer before calling pass.draw.
encoder.begin_render_pass(target, options)
renders into a texture view. Use texture.view()
for sRGB or texture.raw_view()
for linear rgba8unorm, and match the custom pipeline's format to its target.
A pass cannot sample the same texture subresource it renders into. Beginning a new
encoder pass ends the previous one; compute work uses a
compute pass and
dispatch.
See the reference conventions and pitfalls
for snapshot reads, handle lifetimes, and parameter-buffer sizing.