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¶
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 inplugin.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".Ctrlmaps to the platform's primary modifier automatically (Cmdon 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; readctx.commands.effective_shortcut(id)when building your widgets and re-apply on the"command.shortcutChanged"event.
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.
menus¶
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)¶
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)¶
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:
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¶
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;0lets 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 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)
Search¶
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¶
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 byctx.ui.get / ctx.ui.setto address controls inside this panel. If you don't supply one, the host allocates apy-dock/Nid 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 themte.uibuilders.initially_visible(bool) — whether the dock is shown when the plugin loads. Defaults toTrue. 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 themte.uibuilders.scope(str, optional) — event scope emitted onSettingsChangedafter 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.