>_ rs-pug plugins

docs / plugins

Lua plugin API

rs-pug embeds Lua 5.4 via mlua, no system Lua needed. Each .lua file is an isolated plugin. Pick a topic below, each page covers exactly one area, straight from src/plugins.rs.

start: loading and limits

Drop .lua files into ~/.config/rs-pug/plugins (non recursive). File stem becomes the plugin name in logs. Minimal plugin:

plugins/hello.lua lua 5.4
plugin = {}
function plugin.on_key(key, state)
  if key == "char:h" then
    return { consume = true,
      flash = "tab: " .. state.active_tab,
      flash_seconds = 3 }
  end
end
return plugin

How load() treats each file, in order:

step what the code does
scan fs::read_dir on plugins_dir, only .lua files. Order is filesystem order, never assume alphabetical.
state Fresh Lua::new() per file, full stdlib, fully isolated globals.
eval Chunk runs under exec timeout. Use returned table, else global plugin, else error missing plugin table.
env Globals set: ALLOW_LUA_UI_CHANGES (bool copy of config flag) and plugin (your table).

Execution budget, enforced per single hook call via a VM instruction hook: 250 ms wall time, checked every 10,000 instructions. Overrun aborts with: plugin exceeded execution time limit. Hot reload destroys every Lua state and rebuilds from scratch on any .lua change or on config change to plugins_enabled, plugins_dir or allow-lua-ui-changes, and logs a plugins hot-reloaded warning.

config.toml toml
[general]
plugins_enabled = true
plugins_dir = "~/.config/rs-pug/plugins"
[lua]
allow-lua-ui-changes = false

With allow-lua-ui-changes false, on_ui_config, on_ui_sections, on_ui_inject and on_ui_update never run, and ui.layout patches from keys or events are skipped. Everything on the other tabs works in both modes.

keys + events: dispatch

on_key(key, state) fires on every keypress no modal view claimed first (tag editor, option editing, context menu, delete confirm, help, palette and search typing all intercept before plugins). on_event(event, state) fires on every core event including progress, several times per second during playback, so check event.kind first thing. Both return PluginDispatch, nil means do nothing.

dispatch field type effect
consume bool, default false true stops built in key handling (keys only)
flash string status bar message
flash_seconds u64 display time, default about 4
core_actions list, see below all executed in load order
ui patch, see below set_tab, set_search_query, set_album_search_query, set_focus (search, results, queue), set_search_mode bool, set_selected_result, set_selected_album_result, set_selected_queue (0 based, clamped), layout patch

Core actions, tagged by type field: search {query}, search_albums {query}, seek {seconds i32, negative goes back}, toggle_pause, toggle_mute, volume_up, volume_down, next, prev, set_volume {value u8}, play_url {url, optional title}, raw_mpv {command, any JSON, forwarded to mpv IPC unvalidated}.

Event shape: kind string, message string or nil (track title on started, error text on error), value number or nil (result count on search_done and album_search_done, position seconds on progress). Anything else arrives as kind event with no payload, compare state fields instead. Key labels come from describe_key_event_with_modifiers, exact mapping:

physical key label
letter or symbol char:x, case follows Shift for a-z (Shift+z gives char:Z)
enter / esc / tab / backspace enter, esc, tab, backspace
arrows left, right, up, down
pageup / pagedown / Fx page_up, page_down, f1 to f12
home / end / delete / rest other, all share one label

Ctrl and Alt never reach on_key, Ctrl+r and r both arrive as char:r. Use config.toml [keybinds] with C, M, S prefixes for modifiers. Shift alias, from describe_key_event_labels plus dispatch_key_with_aliases: ASCII letters get a second toggled case label. The engine queries every plugin with the primary label first, and only if no plugin produced any effect does it retry with the alias. Effect means consume, flash, flash_seconds, any core action, any ui setter, or any layout patch field. In practice handle one case and the other is tried for free.

plugins/quick_seek.lua lua 5.4
function plugin.on_key(key, state)
  if key == "char:[" then
    return { consume = true,
      core_actions = { { type = "seek", seconds = -15 } } }
  end
  if key == "char:]" then
    return { consume = true,
      core_actions = { { type = "seek", seconds = 15 } } }
  end
end
function plugin.on_event(event, state)
  if event.kind == "started" then
    return { flash = "Now: " .. event.message,
      flash_seconds = 3 }
  end
end

tabs + panels: extra surfaces

These two hooks work with or without allow-lua-ui-changes. Both are polled every frame while state keeps changing. Return nil when idle.

hook return merge
on_tabs(state) PluginTab list: id and title required, icon optional concatenated across plugins, no id dedup, errors skipped silently
on_ui_panels(state) PluginPanel list, see below concatenated, errors skipped silently

PluginPanel: title required, target optional (main, results, queue, overlay, missing behaves like main). Panels on main, results or queue render inside the results or queue area only while a plugin tab or custom tab is active, never on stock tabs. Overlay panels always float top right. Legacy lines list still works: when items is empty it is auto converted to text items by collect_ui_panels. Eight item types, tagged by type: text {text}, info {text, info color}, option {key, value}, stat {label, value}, separator {}, header {text, bold}, keybind {key, action}, progress {optional label, percent f64 clamped 0-100, 10 segment bar}.

plugins/now_overlay.lua lua 5.4
function plugin.on_tabs(state)
  return { { id = "stats", title = "Stats" } }
end
function plugin.on_ui_panels(state)
  return { { title = "Now", target = "overlay",
    items = {
      { type = "stat", label = "state", value = state.player_state },
      { type = "stat", label = "queue",
        value = tostring(state.queue_len) },
      { type = "separator" },
      { type = "progress", label = "buffer", percent = 87 },
    } } }
end

layout + ui: reshape the screen

All four hooks need allow-lua-ui-changes true or they are never called. Shapes are auto detected where noted.

hook return cadence
on_ui_config(state) {tabs, layout}: remove list, order list, rename map id to title/icon, custom tabs; plus full layout once at startup and after every reload, replaces main tab set
on_ui_update(state) layout only. Flat table or {layout} wrapper, detected by presence of layout or tabs key, both shapes accepted every frame on state change
on_ui_sections(state) map of section id to item lists, feeds custom_sections content every frame with inject
on_ui_inject(state) {results_top, results_bottom, queue_top, queue_bottom, statusbar_extra}, each an item list every frame, renders on any tab

Layout fields, all optional, set fields win over current values: queue_width_percent 10-90, visualizer_height 0-10 rows, show_progress_bar, show_volume_bar, show_statusbar, show_keybind_hints bools, tab_bar_position top bottom left right, tabs_width 12-40 cols, queue_position left right, hide list of fixed elements (visualizer, progress_bar, volume_bar, statusbar, keybind_hints), custom_sections list, hide_sections and show_sections lists for your own sections. Do not mix the two systems: hide targets fixed elements, hide_sections targets your custom ids. Custom section: id required non empty unique, position default below_player (above_player, below_player, left, right), width cols for left/right, height rows default 3, content field reserved and currently unused, real content comes only from on_ui_sections under the same id. Custom tab: id and title required, icon optional, position 1 based and clamped. Removing every main tab without replacement restores defaults with a warning.

plugins/compact_ui.lua lua 5.4
if not ALLOW_LUA_UI_CHANGES then return plugin end
function plugin.on_ui_config(state)
  return { tabs = { custom = {
    { id = "dash", title = "Dash", position = 2 } } },
    layout = { queue_width_percent = 35,
      show_keybind_hints = false,
      custom_sections = {
        { id = "clock", position = "above_player" } } } }
end
function plugin.on_ui_sections(state)
  return { clock = {
    { type = "text", text = os.date("%H:%M") } } }
end
function plugin.on_ui_update(state)
  if state.queue_len == 0 then
    return { queue_width_percent = 20 }
  end
  return { queue_width_percent = 35 }
end

state: read only snapshot

Every state taking hook receives PluginUiState, built by from_app. Never mutate it, express changes through return values. All 13 fields:

field type notes
active_tab string discover, albums, library, local, options, or custom id. Active custom tab wins over main tabs.
active_plugin_tab string or nil id from on_tabs when one is active
active_custom_tab string or nil id from on_ui_config when one is active
active_tab_index number, 0 based covers main plus plugin tabs
current_layout object queue_width_percent, visualizer_height, tab_bar_position, tabs_width, queue_position
visible_sections string list currently non hidden custom section ids
player_state string label like playing, paused, idle, searching
volume number 0-100 u8 in code
muted bool
repeat_mode string off, one, all, lowercase label
search_query string Discover field text
album_search_query string Albums field text
queue_len number track count in queue

Typical pattern: branch on state, return nil otherwise. Frame polled hooks run only while state changed since last frame, so cheap reads are fine but keep IO out.

state driven layout lua 5.4
function plugin.on_ui_update(state)
  if state.active_tab == "queue" then
    return { queue_width_percent = 60 }
  end
  if state.muted then
    return { show_volume_bar = false }
  end
end

merging: many plugins, one result

Load order is filesystem order and not guaranteed. Rules below come straight from merge_dispatch, merge_ui_config, merge_layout_config and the collectors. Note the asymmetry: dispatch scalars are first wins, layout scalars are last wins.

area rule in code
search / song hooks pipeline via run_fold, each output feeds the next plugin
consume logical OR, one true is enough
flash, flash_seconds, ui setters first plugin that sets the field wins, later values ignored
core_actions concatenated in load order, all executed
ui.layout scalars last plugin that sets the field wins, opposite of the rest
hide_sections / show_sections concatenated everywhere they appear
on_tabs / panels / inject concatenated lists, no dedup
config remove / custom / sections concatenated; sections map extends so last wins per section id
config order last non empty order list wins
config rename hashmap extend, last wins per tab id
layout config scalars last Some overwrites, via merge_layout_config

Cooperation advice from the code: give each field exactly one owner plugin. Return nil instead of empty tables from frame hooks so last wins merges do not get clobbered by accident.

debug: warnings and safety

Warning queue holds 100 entries, oldest dropped first. Only the newest shows in the status bar. Format, from PluginWarning label: Lua WARN [name.hook]: message. What warns:

situation level
cannot read plugin dir or file warning or error with file path
load failed (syntax, timeout) error, remaining files still load
missing plugin table error, file skipped
hook is not a function warning, treated as absent
UI hook runtime or timeout fail error_hook call failed
bad dispatch or layout shape error_invalid dispatch return / invalid return shape
unknown tab ids, bad positions, clamped numbers, emptied tab set warning, renderer falls back

What stays silent by design: runtime errors and timeouts inside on_search_query, on_search_results, on_song_start, on_key, on_event, on_tabs and on_ui_panels skip that plugin with no entry anywhere, and shape mismatches in the three transform hooks silently keep the previous value. If such a hook seems dead, run rs-pug --debug and read ~/.config/rs-pug/rs-pug.log, then test the fragment in standalone lua5.4. Trust boundary: full stdlib including io and os.execute, same user as the player process, no sandbox beyond the 250 ms budget, .lua files execute on drop with no confirmation, raw_mpv forwards raw JSON to mpv IPC. Frame hooks (on_event progress, on_ui_update, on_ui_sections, on_tabs, on_ui_panels) run constantly: filter on event.kind first, do no IO, allocate little.