Skip to content

ctx — the plugin context

register(ctx) receives one argument: a PluginContext. It exposes five services in Phase 1. Each is a small Python class the worker builds on top of the RPC channel; the source is Python/mte/.

def register(ctx):
    ctx.commands       # register/invoke commands
    ctx.menus          # add menu items & separators
    ctx.events         # subscribe to editor events
    ctx.log            # write to the host's diagnostics stream
    ctx.status         # post transient status-bar messages
    ctx.editor         # buffer / caret / selection access (Phase 2)
    ctx.settings       # per-plugin key/value store (Phase 2)
    ctx.app_settings   # editor-wide read-only settings view (Phase 2)
    ctx.completions    # register auto-completion providers (Phase 2)
    ctx.docks          # declarative dock panels (Phase 3)
    ctx.settings_page  # declarative Preferences pages (Phase 3)
    ctx.ui             # runtime get/set for panel controls (Phase 3)

Do not store ctx in a global expecting to use it later — while the attributes are stable for the plugin's lifetime, treat ctx as per-registration. Keep references to the individual services if you need them (log = ctx.log, for example).


commands

ctx.commands.command(*, id, title, shortcut="", category="", scope="")

Decorator that registers a command and binds the wrapped callable as its handler.

  • id (str, required): reverse-DNS-style unique identifier (e.g. "wordcount.count"). Same rules as in plugin.json — must be globally unique.
  • title (str, required): human-readable label shown wherever the command is surfaced (menus, command palette).
  • shortcut (str, optional): portable default shortcut string, e.g. "Ctrl+Alt+W". Ctrl maps to the platform's primary modifier automatically (Cmd on macOS). Leave empty for none. The user can rebind or unbind it in Preferences ▸ Shortcuts; for menu-bound commands the host applies the remap for you.
  • category (str, optional): free-form grouping label — shown as the command's category in the Shortcut Mapper.
  • scope (str, optional): ""/"application" (default — the host binds the shortcut on the command's menu action) or "pluginWindow" for chords living in plugin-owned UI: the host only lists the command in the mapper; read ctx.commands.effective_shortcut(id) when building your widgets and re-apply on the "command.shortcutChanged" event.
ctx.commands.effective_shortcut(command_id) -> str

The shortcut currently in effect after user remapping and conflict resolution ("" = unbound or unknown). Safe to call from handlers.

The wrapped function takes no arguments and returns nothing. Exceptions inside it are caught by the worker and logged to stderr; they do not crash the editor.

@ctx.commands.command(id="wordcount.count",
                      title="Count Words",
                      shortcut="Ctrl+Alt+W",
                      category="Text")
def _count():
    ctx.status.show("counted")

Under the hood: the Python side allocates a handlerId locally, registers the callback on the RPC connection, and fires commands.registerCommand at the host. When the user activates the command (menu click, shortcut, palette), the host sends a callback RPC and your function runs.

Command ids are process-wide, not per-plugin. Prefix your ids with your reverse-DNS namespace (wordcount.…) to avoid collisions.


Menu paths use / as separator. The leftmost segment is a top-level menu (Plugins, Tools, …); intermediate segments create submenus on demand; the final segment is the item label.

ctx.menus.add_item(path, command_id)

ctx.menus.add_item("Plugins/Word Count/Count", "wordcount.count")
  • path (str): "Top/Sub/…/Label". Intermediate submenus are created if they don't exist.
  • command_id (str): id of a previously-registered command. Clicking the item calls the command.

Returns an opaque handlerId string. Keep it if you'll need to unsubscribe the item later (rare in Phase 1).

The leftmost segment ("Plugins" above) must match the title of one of your menus[] entries in plugin.json so the menu-bar placement is picked up.

ctx.menus.add_separator(path)

ctx.menus.add_separator("Plugins/Word Count")

Inserts a separator into an existing submenu.

ctx.menus.set_item_checked(handler_id, checked)

Turns the previously-added item into a checkable menu entry and sets its checked state — the standard idiom for a persistent on/off toggle that shows a visible check mark.

item = ctx.menus.add_item("View/Auto-open Preview",
                          "myplugin.toggleAutoOpen")

# Seed the check mark from the persisted setting so it matches
# reality before the user opens the menu.
ctx.menus.set_item_checked(item, ctx.settings.get("autoOpen", True))

@ctx.commands.command(id="myplugin.toggleAutoOpen",
                      title="Toggle Auto-Open")
def _toggle():
    on = not ctx.settings.get("autoOpen", True)
    ctx.settings.set("autoOpen", on)
    ctx.menus.set_item_checked(item, on)

The first call promotes the item to checkable; subsequent calls just flip the mark. Passing a handler_id the host doesn't know is a silent no-op (safe to call after your token has been dropped, e.g. from a delayed callback firing during shutdown).


events

Subscribe to editor events. Two equivalent APIs — the sugar decorators are recommended for the fixed set of Phase 1 topics; the raw subscribe() is available if you want to compute the topic at runtime.

Sugar decorators

@ctx.events.on_document_saved
def _saved(ev):
    ctx.log.info(f"saved doc {ev.document}")

@ctx.events.on_document_opened
def _opened(ev):
    ctx.log.info(f"opened {ev.path}")

The wrapped function takes one argument: a typed dataclass corresponding to the topic. See the Events reference for the full topic ↔ dataclass table.

Raw API

handler_id = ctx.events.subscribe("document.saved", _saved)
# later:
ctx.events.unsubscribe(handler_id)

Available sugar properties (all decorators):

Property Fires on
on_document_opened Document opened in a tab
on_document_closed Document closed
on_document_saved Document written to disk
on_selection_changed Caret / selection moved in the active document
on_active_document_changed Foreground tab changed
on_app_about_to_quit Editor is closing — last chance to run
on_command_shortcut_changed A command's effective shortcut changed (user remap / keymap import / conflict resolution); payload: command_id, sequence ("" = unbound)

Unknown topic names raise ValueError at subscribe time. Phase 1 subscribers may run after the corresponding native handler; treat event delivery as informational, not authoritative.


log

ctx.log.trace  ("very-fine-grained")
ctx.log.debug  ("dev-only detail")
ctx.log.info   ("routine event")
ctx.log.warning("recoverable weirdness")
ctx.log.error  ("something is wrong")

Each level maps to the host's ILogger. Lines are prefixed [python:<plugin.id>] so your output is discoverable in the editor's diagnostics log:

[python:org.example.wordcount] counted 42 words

Never print() from a plugin — worker stdout is the RPC channel, and writing to it corrupts the protocol. ctx.log.* is the only sanctioned way to emit diagnostic text.


status

ctx.status.show(message, timeout_ms=0, level="info")

Post a transient notification to the status bar.

  • message (str): what to show. Short strings only — the status bar has a fixed width.
  • timeout_ms (int, default 0): how long to keep it on screen; 0 lets the host pick a sensible default (a few seconds).
  • level (str, default "info"): "info" / "warning" / "error". Higher levels may hold the on-screen minimum a little longer so an error is not instantly wiped by a routine info message.
ctx.status.show("saved", timeout_ms=1500)
ctx.status.show("no results", level="warning")

ctx.status.show is fire-and-forget. If the host has no visible status bar (headless / minimal build), the call is silently dropped.



editor

Full sync access to the active document(s). Every method blocks the calling thread until the host responds (typically microseconds when the worker and editor share a machine). Legal from any command / event handler; never from an async def callback (see the threading note at the top of this page).

Document enumeration

doc  = ctx.editor.active_document()      # 0 when no document is open
docs = ctx.editor.open_documents()       # list[int]
ctx.editor.set_active_document(doc)
path = ctx.editor.file_path(doc)         # str or None for untitled

Buffer access

text  = ctx.editor.text(doc)                        # whole buffer
slice = ctx.editor.text_range(doc, start, end)      # [start, end) bytes
n     = ctx.editor.length(doc)                      # int64
ctx.editor.set_text(doc, "...")

text_range avoids pulling the whole buffer across the RPC when you only need a slice — prefer it for sort/transform on big files.

Selection

sel = ctx.editor.selection_range(doc)               # mte.MatchRange(start, end)
s   = ctx.editor.selected_text(doc)
ctx.editor.replace_selection(doc, "new text")
ctx.editor.set_selection(doc, mte.MatchRange(4, 9))

Caret

pos = ctx.editor.caret(doc)                         # mte.EditorPosition(line, column)
ctx.editor.set_caret(doc, mte.EditorPosition(line=7, column=3))

File ops

new_doc = ctx.editor.open_file("/path/to/file")     # doc-id or 0 on failure
ok      = ctx.editor.save_document(doc)
dirty   = ctx.editor.is_modified(doc)
opts = mte.SearchOptions(match_case=True, mode="regex")
hit  = ctx.editor.find_in_range(doc, r"TODO\(.*\)", 0,
                                ctx.editor.length(doc), opts)
if hit.valid:
    new_end = ctx.editor.replace_target(doc, hit, "DONE()", opts)

SearchOptions.mode: "normal" / "extended" / "regex". SearchOptions.direction: "forward" / "backward". See mte.SearchOptions for the full field list.

Indentation and syntax

style = ctx.editor.indentation(doc)                 # IndentationStyle(use_tabs, width)
ctx.editor.set_indentation(doc,
    mte.IndentationStyle(use_tabs=False, width=2))

lang  = ctx.editor.colorizer_id(doc)                # e.g. "python", "" if none

settings

Persistent typed key/value store, scoped to plugins/<your.id>/ on the host. All methods synchronous; get/set are inferred from the default's Python type.

ctx.settings.set("wrap", True)
ctx.settings.set("count", 42)
ctx.settings.set("theme", "dark")

wrap  = ctx.settings.get("wrap", False)             # returns bool
count = ctx.settings.get("count", 0)                # returns int
theme = ctx.settings.get("theme", "light")          # returns str

Types:

Python type of default Wire type Return type from get()
bool bool bool
int (non-bool) int int
float double float
str (or None) string str

Also:

ctx.settings.contains("wrap")           # bool
ctx.settings.remove("stale_key")
ctx.settings.sync()                     # optional; host also syncs on shutdown

React to changes made in the Preferences dialog by subscribing to SettingsChanged (see Events reference; Phase 3 for the full event surface).

app_settings

Read-only view of the editor's app-wide config. Key names live in the C++ header PluginApi/AppSettingsKeys.h; check the header for the authoritative list.

tab_width = ctx.app_settings.get("editor.tabWidth", 4)
theme     = ctx.app_settings.get("editor.theme.variant", "light")

Setters, remove(), and sync() on ctx.app_settings raise RuntimeError — the view is intentionally read-only.


completions

Register a completion provider that participates in the editor's popup:

@ctx.completions.provider(languages=["python"])
def _complete(req: mte.CompletionRequest):
    if not req.prefix:
        return []
    return [mte.CompletionItem(text=w, kind=mte.KIND_KEYWORD)
            for w in ("hello", "hello_world", "helper")
            if w.startswith(req.prefix)]

The wrapped callable is invoked for every keystroke (or Ctrl+Space) whose document matches the language filter. Fields on req:

Field Type Meaning
document int Document being edited
prefix str Word fragment immediately before the caret
position int Caret byte offset
language_id str Colorizer id ("python", "cpp", …)
manual bool True when the user pressed Ctrl+Space; False for auto-trigger

Return a list of mte.CompletionItem (or plain strings, or dicts with {text, detail, kind}). Kinds: mte.KIND_KEYWORD / KIND_WORD / KIND_SYMBOL / KIND_SNIPPET / KIND_UNKNOWN.

Deadline

The host sends your provider a keystroke request with a ~30 ms deadline. If your callback misses it, the popup shows without your candidates for that keystroke. Build any expensive index in the background and answer from cache — never do I/O in the callback. LSP-style network completion belongs in a future async surface, not here.

languages=[] (or omitted) means "every document". Multiple providers per plugin are allowed; each decorator registers its own.


data_dir

path = ctx.data_dir()                    # pathlib.Path

Returns the per-plugin writable directory the host has already created for you. Use for caches, indexes, and any data you own. ctx.diagnostics_dir() similarly returns the shared crash/log directory (read-only for you — the crash reporter writes there).


docks

Declarative dock panels — a side-panel UI built from a form spec, not from Qt widgets. See mte.ui for the builder vocabulary.

from mte.ui import Column, Row, Label, LineEdit, Checkbox, Button

def register(ctx):
    def _on_needle_changed(value: str) -> None:
        ctx.log.info(f"needle -> {value!r}")
    needle_handler = ctx.docks.on_change(_on_needle_changed)

    panel_id = ctx.docks.add_panel(
        id="renamer.panel",
        title="Renamer",
        area="right",             # left / right / top / bottom
        form=Column([
            Label(text="Search:"),
            LineEdit(id="needle", placeholder="text...",
                     binds="settings:lastNeedle",
                     on_change=needle_handler),
            Checkbox(id="cs", label="Case sensitive",
                     binds="settings:caseSensitive"),
            Row([Button(text="Run", on_click="renamer.run")]),
        ]),
    )

ctx.docks.add_panel(*, id=None, title="", area="right", form, initially_visible=True) -> str

Registers a dock panel. Returns the panel id (auto-allocated when id is None).

  • id (str, optional) — stable identifier used by ctx.ui.get / ctx.ui.set to address controls inside this panel. If you don't supply one, the host allocates a py-dock/N id and returns it.
  • title (str) — shown on the dock header.
  • area (str)"left" / "right" / "top" / "bottom". Unknown values fall back to "right".
  • form (dict) — a spec built with the mte.ui builders.
  • initially_visible (bool) — whether the dock is shown when the plugin loads. Defaults to True. The user can hide it afterwards; you cannot force it back open.

ctx.docks.on_change(callback) -> str

Allocates a handler id for wiring a control's on_change field to a Python callable. The callback receives the new value as a string; boolean controls deliver "true" / "false", numeric controls deliver the integer as a string.

def _on_volume(value: str) -> None:
    ctx.log.info(f"volume={int(value)}")

handler = ctx.docks.on_change(_on_volume)
form = Column([NumberSpin(id="volume", default=5, on_change=handler)])

on_change handler ids are prefixed py-dock-cb/N and never collide with command / event / completion handler ids.

The callback is invoked on the worker's event loop, NOT the thread pool — keep it fast. Use ctx.log, mutate a small in-memory state, or fire a follow-up command; don't do heavy work here.


settings_page

Adds a page under the editor's Preferences dialog. Same form spec vocabulary as dock panels; same binds="settings:<key>" persistence.

from mte.ui import Column, Checkbox, NumberSpin

def register(ctx):
    ctx.settings_page.register(
        category="Plugins/Renamer",       # slash-separated tree path
        title="Renamer",
        form=Column([
            Checkbox(id="cs", label="Case sensitive by default",
                     binds="settings:caseSensitive"),
            NumberSpin(id="results", default=100, min=1, max=10_000,
                       binds="settings:resultCap"),
        ]),
    )

ctx.settings_page.register(*, category, title, form, scope="") -> str

  • category (str, required) — slash-separated path in the Preferences tree, e.g. "Plugins/My Plugin". Nested categories arrive as tree nodes.
  • title (str, required) — heading shown above the page.
  • form (dict, required) — a spec built with the mte.ui builders.
  • scope (str, optional) — event scope emitted on SettingsChanged after the user hits OK / Apply.

Returns the handler id.

Cancel does NOT roll back changes

Controls with binds="settings:<key>" persist eagerly as the user edits, so hitting Cancel in the Preferences dialog does not undo them. This is a known Phase 3 limitation; a future refinement will wrap the underlying settings in a buffering proxy.


ui

Reads and writes control values inside panels you already registered. Values are always strings on the wire.

def register(ctx):
    panel_id = ctx.docks.add_panel(id="renamer.panel", ...)

    @ctx.commands.command(id="renamer.echo", title="Echo needle")
    def _echo():
        needle = ctx.ui.get(panel_id, "needle") or ""
        cs = (ctx.ui.get(panel_id, "cs") or "false") == "true"
        ctx.status.show(f"needle={needle!r} case={cs}")

    @ctx.commands.command(id="renamer.clear", title="Clear needle")
    def _clear():
        ctx.ui.set(panel_id, "needle", "")

ctx.ui.get(panel_id, control_id) -> Optional[str]

Reads the current value of a control. Returns None when the panel or control is unknown (not an error — treat both None and "" as "empty").

  • Label / Button / LineEdit / ComboBox — returns the text.
  • Checkbox — returns "true" or "false".
  • NumberSpin — returns the integer as a decimal string.
  • List — returns the currently selected item's text (empty when nothing is selected).

ctx.ui.set(panel_id, control_id, value) -> bool

Writes a value into a control programmatically. Returns True when the write succeeded, False when the panel/control is unknown or the value can't be coerced (e.g. a non-integer sent to a NumberSpin).

Python bool True / False are converted to "true" / "false" before being sent; everything else goes through str().

Host-driven set does NOT re-fire the control's on_change callback, so you can update your own widgets from a command handler without infinite loops.


Runtime bindings summary (Phase 3)

Form spec field Runtime behaviour
id="…" Address the control from ctx.ui.get / ctx.ui.set.
binds="settings:<key>" Reads initial value from ctx.settings; writes back on every user edit.
on_click="<cmd>" Buttons invoke the registered command id on click.
on_change=handler Fires the callback registered via ctx.docks.on_change(...) on user edit.

Same rules apply on dock panels and Preferences pages.