Skip to content

ctx — the plugin context

register(ctx) receives one argument: a PluginContext. It exposes the services below (the whole IPluginContext surface that fits the out-of-process model). 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 / files / search / bookmarks / groups
    ctx.languages      # list languages, read / switch a document's language, contribute one
    ctx.settings       # per-plugin key/value store
    ctx.app_settings   # editor-wide read-only settings view (keys: mte.keys)
    ctx.document_settings  # .editorconfig-layered per-document reads
    ctx.theme          # themeable colour categories for your own UI
    ctx.diagnostics    # publish squiggles + margin glyphs
    ctx.compare        # open the side-by-side compare view
    ctx.completions    # register auto-completion providers
    ctx.docks          # declarative dock panels (+ set_panel_visible, is_panel_visible)
    ctx.line_markers   # margin glyphs / line tints that follow edits; the margin-click claim
    ctx.settings_page  # declarative Preferences pages
    ctx.ui             # runtime get/set for panel controls

Two parts of the native contract have no Python counterpart yet: hover providers (so the hover priority bands do not apply to Python plugins) and editor context-menu providers (so neither does the context menu's text / margin area). Both are answered while the user waits, which needs the same deadline treatment completion providers have before a round trip to the worker is safe.

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).

ctx.menus.set_item_enabled(handler_id, enabled)

Enables or greys out a previously-added item. Use it when the command needs a precondition — something to close, something to restore — so the menu shows it is unavailable instead of offering an entry that quietly does nothing.

item = ctx.menus.add_item("File/Close Thing", "myplugin.closeThing")

# Nothing to close yet:
ctx.menus.set_item_enabled(item, False)

# ... and from every place that changes the state the command needs:
ctx.menus.set_item_enabled(item, thing_is_open)

A disabled item stays visible but cannot be triggered, by click or by its shortcut. The call is idempotent, so you never have to remember the previous state, and an unknown handler_id is a silent no-op as above. Items start out enabled: one call right after add_item seeds an item that is unavailable at start-up.


events

Subscribe to editor events. Two equivalent APIs — the sugar decorators are recommended; 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_document_modified_changed The clean/dirty flag toggled (modified: bool)
on_text_changed Every edit (position, added_bytes, removed_bytes) — keep it trivial
on_settings_changed Preferences applied (scope: "" editor-wide, "plugins/<id>")
on_theme_changed Theme switched or edited (variant) — re-read ctx.theme colours
on_workspace_root_changed Workspace folder opened / closed (root)
on_workspace_files_changed Workspace file set changed (files)
on_host_window_shown Once per run, after the first frame — schedule slow startup work here
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. Subscribers may run after the corresponding native handler; treat event delivery as informational, not authoritative.

Publishing

ctx.events.publish(topic, payload) broadcasts to native and Python subscribers alike — accepted only for the two workspace topics (workspace.rootChanged, workspace.filesChanged); anything else raises ValueError. See the Events reference.


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. ctx.status.clear() wipes whatever transient notification is on screen.

Persistent segments

For live state (a word count, a server status) use a segment you own and refresh — it never clobbers a notification and vice versa:

seg = ctx.status.add_segment("wordcount.total", priority=100)
seg.set_text("2,431 words")
seg.set_visible(False)      # hide without giving it up
seg.remove()                # take it off the bar (idempotent)

priority orders plugin segments left to right (lower = further left; ties keep registration order). Plugin shutdown removes anything still held.


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))

ranges = ctx.editor.selection_ranges(doc)           # list[MatchRange]: one per caret
block  = ctx.editor.is_rectangular_selection(doc)   # Alt+drag column block?

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
here    = ctx.editor.open_file_at("/path/to/file", mte.EditorPosition(41, 0))
ctx.editor.reveal_line(doc, 120)                    # scroll there; caret and focus stay
ok      = ctx.editor.save_document(doc)
closed  = ctx.editor.close_document(doc)            # prompts if modified; False = user cancelled
dirty   = ctx.editor.is_modified(doc)
ctx.editor.set_modified(doc, True)                  # force the tab asterisk
ro      = ctx.editor.is_read_only(doc)              # e.g. Monitoring (tail -f) mode
enc     = ctx.editor.document_encoding(doc)         # mte.DocumentEncoding, e.g. UTF8_BOM
ctx.editor.set_document_encoding(doc, mte.DocumentEncoding.UTF8)  # takes effect on the next save
eol     = ctx.editor.line_ending(doc)               # mte.LineEnding, e.g. CRLF
ctx.editor.set_line_ending(doc, mte.LineEnding.LF)  # Enter inserts it; the next save writes it
scratch = ctx.editor.create_untitled("text", "Untitled-3")   # new buffer, doc-id
sid     = ctx.editor.stable_id(doc)                 # survives Save As; compare with ==
clip    = ctx.editor.clipboard_text()               # system clipboard, "" if no text

# Declare that you snapshot unsaved buffers (the host then stops asking
# "save changes?" when the window closes):
ctx.editor.set_unsaved_changes_preserved(True)
ctx.editor.unsaved_changes_preserved()              # bool, global flag

open_file_at is the one call for "take the user there" (a build error, a stack frame, a search hit): it opens the file or activates its tab, places the caret with no selection, unfolds a fold that hides the line, scrolls the line into view — centred when it was off-screen — and focuses the editor. Line and column are 0-based (the column in UTF-8 bytes) and clamped, never refused; it returns the document id, or 0 when the file could not be opened, and then nothing moved. reveal_line only shows a line: it does not move the caret, switch tabs or take focus.

Bookmarks, marks, URL highlight

ctx.editor.toggle_bookmark(doc, line)               # 0-based line
lines = ctx.editor.bookmarked_lines(doc)            # list[int]
nxt   = ctx.editor.next_bookmark(doc, from_line, wrap=True)      # -1 = none
prv   = ctx.editor.previous_bookmark(doc, from_line, wrap=True)
ctx.editor.clear_bookmarks(doc)

ctx.editor.mark_range(doc, mte.MatchRange(4, 9))    # "mark" indicator
ctx.editor.clear_marks(doc)

ctx.editor.set_url_highlight_enabled(doc, True)
ctx.editor.is_url_highlight_enabled(doc)
url = ctx.editor.url_at_caret(doc)                  # "" when none
ctx.editor.open_url_at_caret(doc)                   # bool
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.

Incremental search (grow the pattern as the user types, from a pinned anchor):

ctx.editor.set_incremental_search_anchor(doc)
hit = ctx.editor.incremental_search(doc, "TOD", opts)   # MatchRange

Indentation and syntax

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

# Where word-wrapped continuation rows start:
# "left" (window edge) / "same" (align with the line; default) / "indent"
ctx.editor.set_wrap_indent(doc, "same")
mode = ctx.editor.wrap_indent(doc)                  # same strings back

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

# Semantic classification from the active colorizer (cheap, no re-lex):
# list[mte.CategorySpan(start, length, category)] with category names
# "comment" / "string" / "keyword" / ... -- a spell checker keeps to
# comments and strings this way.
spans = ctx.editor.text_categories(doc, 0, ctx.editor.length(doc))

ctx.editor.trigger_completion(doc)                  # pop the list as if Ctrl+Space

Split view / tab groups

n  = ctx.editor.group_count()                       # >= 1
g  = ctx.editor.group_of(doc)                       # -1 if not open
w  = ctx.editor.group_weights()                     # relative widths
ctx.editor.move_document_to_group(doc, 1)           # creates trailing groups on demand
ctx.editor.set_group_weights([2, 1])
ctx.editor.set_document_tab_index(doc, 0)           # reorder within its pane

gs = ctx.editor.groups_of(doc)                      # EVERY pane showing it
ctx.editor.show_document_in_group(doc, 1)           # split it into pane 1

A document shown in two panes is one document with one id, so group_of() can name only one of its seats. groups_of() reports them all, and show_document_in_group() creates a second shared view -- the same thing View > Split Document Right does, so no document_opened event is raised.

ctx.editor now mirrors the native IEditorService one to one.


languages

Syntax colouring is a core feature — the host detects a document's language and colours it without any plugin. ctx.languages lets you join in: list what the host knows, read or override a document's language, re-run detection, and contribute whole language definitions.

for lang in ctx.languages.available():              # list[mte.LanguageInfo(id, name)]
    ...
lang_id = ctx.languages.language_for(doc)           # "" when uncoloured
ok      = ctx.languages.set_language_for(doc, "json")   # "" clears; False = unknown
ctx.languages.auto_detect_for(doc)                  # undo a manual override

handler = ctx.languages.register(
    id="mylang", name="My Language", lexer="cpp",
    extensions=["mylang", "ml2"],
    keywords=[{"group": "Primary keywords and identifiers",
               "words": "if else while"}],
    styles={"SCE_C_COMMENT": "comment", "SCE_C_STRING": "string"})
...
ctx.languages.unregister(handler)                   # restores what it displaced

register takes the same shape as a shipped colorizers/*.json file (or a whole definition= dict): lexer names an existing backend lexer (the host gains no new lexers at runtime), styles maps that lexer's own style names onto semantic categories, and keywords addresses its word lists by the lexer's own slot description — the host resolves both exactly as it does for a shipped file, so you never spell out engine ids. Registering an id that already exists replaces it for as long as you hold the handler id; plugin shutdown withdraws anything still held.


document_settings

Read-only, document-scoped settings that layer a project's .editorconfig over your own ctx.settings. For key k and document doc, a read resolves mte.<plugin.id>.<k> from the .editorconfig that applies to that document, then falls back to ctx.settings, then to your default:

wrap  = ctx.document_settings.get_bool(doc, "wrap", False)
width = ctx.document_settings.get_int(doc, "width", 80)
ratio = ctx.document_settings.get_double(doc, "ratio", 1.0)
name  = ctx.document_settings.get_string(doc, "profile", "")
ctx.document_settings.contains(doc, "wrap")          # set in either layer?

Writes go through ctx.settings — never back into .editorconfig.


diagnostics

Push squiggles and margin glyphs for a document — from a linter, a language server, a build log. The host renders them; you never touch the view. Publishing replaces everything you previously published for that (document, source) pair, so send the complete set each time:

ctx.diagnostics.publish(doc, "mylinter", [
    mte.Diagnostic(start=12, end=18, message="unused variable",
                   severity=mte.SEVERITY_WARNING, code="W0612"),
])
ctx.diagnostics.publish(doc, "mylinter", [])        # retract only your own
ctx.diagnostics.clear(doc)                          # clears EVERY producer's set on doc

source is required — it is what keeps your set separate from other producers' (the LSP plugin, the spell checker) on the same document. Severities: SEVERITY_ERROR / SEVERITY_WARNING / SEVERITY_INFORMATION / SEVERITY_HINT. Offsets are half-open [start, end) byte ranges.


theme

Register the colours your own UI paints as named, themeable categories; the Theme Editor lists them automatically and the user's overrides round-trip in the theme file:

h = ctx.theme.register_category("com.example.my/match", "Match highlight",
                                default_light=0xFFF3B0, default_dark=0x8A6D00)
rgb     = ctx.theme.resolve_color("com.example.my/match")   # 0xRRGGBB int
themed  = ctx.theme.has_override("com.example.my/match")
variant = ctx.theme.active_variant()                        # "light" / "dark" / ""
cats    = ctx.theme.categories()                            # list[mte.ThemeCategory]
ctx.theme.unregister_category(h)

Re-read resolve_color on ctx.events.on_theme_changed.


compare

Open (or focus) the editor's side-by-side compare view over two sources. Each side is a file on disk (path=), literal text (content=), or both (content is shown, the path gives session persistence a restorable identity):

shown = ctx.compare.open(
    mte.CompareSource("before", path="/tmp/a.txt"),
    mte.CompareSource("after", content=new_text),
    ignore_whitespace=True)                          # ignore_case / ignore_eol too

Returns False when a side cannot be read.


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 settings.changed (ctx.events.on_settings_changed; see the Events reference).

app_settings

Read-only view of the editor's app-wide config. The well-known key names are exported as constants in mte.keys (mirroring the C++ header PluginApi/AppSettingsKeys.h, which stays the authoritative list):

import mte
size   = ctx.app_settings.get(mte.keys.EDITOR_FONT_SIZE, mte.keys.DEFAULT_EDITOR_FONT_SIZE)
family = ctx.app_settings.get(mte.keys.EDITOR_FONT_FAMILY, "")   # "" = host default
theme  = ctx.app_settings.get(mte.keys.UI_THEME, mte.keys.DEFAULT_UI_THEME)  # "system"/"light"/"dark"

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, proportional_size=0.0, tab_group="") -> 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. Pass False for a panel that should not pop up on startup and reveal it later with set_panel_visible.
  • proportional_size (float) — the panel's default size as a share of the main window: its width in the "left" / "right" areas, its height in "top" / "bottom". 0.0 (the default) keeps the pixel behaviour a tool panel wants — the panel holds its size and a window resize leaves it alone. A fraction such as 0.4 is what a preview wants: maximising the window widens the panel with it. Only the default is yours; once the user drags the panel's separator the host adopts and remembers their share. Out-of-range values are clamped, and the host keeps a minimum extent for both the panel and the editor. See proportionalSize for the full rules.
  • tab_group (str) — panels with the same non-empty group id in the same area share one dock as tabs instead of sitting side by side. Namespace it like a panel id ("renamer/panels"). Only the default arrangement: a layout the user saved wins. See tabGroup.

ctx.docks.is_panel_visible(handler_id) -> bool

Whether a panel added with add_panel is open — a panel tabbed behind another counts as open; one registered with initially_visible=False and never shown, a closed one and an unknown id do not. Pair it with set_panel_visible to show your panels for a task and put back what the user had:

was_open = ctx.docks.is_panel_visible(panel)
ctx.docks.set_panel_visible(panel, True)
# ... when the task ends:
ctx.docks.set_panel_visible(panel, was_open)

ctx.docks.set_panel_visible(handler_id, visible)

Reveals or hides a panel added with add_panel. Showing also raises the panel above the docks it shares an area with, so a command that "opens" its panel really puts it in front of the user. Idempotent — repeat the same value freely, no state to track; silently ignored for an unknown id.

panel = ctx.docks.add_panel(id="mine.panel", title="Mine", form=form,
                            initially_visible=False)

@ctx.commands.command("mine.show", title="Show My Panel")
def _show():
    ctx.docks.set_panel_visible(panel, True)

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.


line_markers

Margin glyphs and whole-line tints attached to document lines that follow the user's edits — breakpoints, a current-statement arrow, change strips — and the claim on the click in the bookmark column. The native service and all its rules are described under Line markers & the margin click.

bp_colour = ctx.theme.register_category("myplugin.breakpoint", "Breakpoint",
                                        default_light=0xCC0000,
                                        default_dark=0xFF5555)
bp = ctx.line_markers.register_kind(glyph=mte.GLYPH_CIRCLE,
                                    glyph_color="myplugin.breakpoint",
                                    line_tint="", z_order=50)

marker = ctx.line_markers.add_marker(bp, doc, line)   # 0 = refused
ctx.line_markers.line_of(marker)                      # where it is NOW; -1 = gone
ctx.line_markers.markers(bp, doc)                     # [mte.LineMarkerPlacement(id, line)]
ctx.line_markers.remove_marker(marker)
ctx.line_markers.clear_markers(bp, doc)               # doc 0 = every document
ctx.line_markers.release(bp)                          # removes the kind and its markers
  • register_kind(glyph=, glyph_color=, line_tint=, z_order=) — glyph is one of mte.GLYPH_NONE (tint only), GLYPH_CIRCLE, GLYPH_CIRCLE_HOLLOW, GLYPH_CIRCLE_BAR, GLYPH_ARROW, GLYPH_STRIP; the colours are ids of categories you registered with ctx.theme. The editor has only a few marker slots for all plugins together, so this can fail — it raises RpcError then. Register your kinds once, in register().
  • add_marker(kind, doc, line) — 0-based line, not clamped: a line outside the document returns 0 and places nothing.
  • Markers move with their lines, survive a reload on the same line number, and disappear with their document — re-add yours on ctx.events.on_document_opened.

The margin click

@ctx.events.on_margin_clicked
def _clicked(ev):                       # mte.MarginClicked
    if ev.modifiers & mte.MODIFIER_CONTROL:
        edit_condition(ev.document, ev.line)
    else:
        toggle_breakpoint(ev.document, ev.line)

claim = ctx.line_markers.claim_margin_click()

Every left click in the bookmark column is published as margin.clicked (document, line, margin = "symbol", modifiers — bits mte.MODIFIER_SHIFT / MODIFIER_CONTROL / MODIFIER_ALT / MODIFIER_META). By default the editor also toggles a bookmark on it; while any plugin holds claim_margin_click() it does not, and leaves the click to the plugins. ctx.line_markers.release(claim) gives the click back.


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.