rxx scripting guide

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.

overview

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.

The mode indicator during selection, dragging, pasting, and command entry.

registering commands

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.

other events

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 lifecycle

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

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
}

custom script modes

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.

GPU pipeline

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.

drawing sprites

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.

previews and edits

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.

live artwork and floating panels

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.

custom GPU work

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.