Saltar a contenido

Crear un plugin de Python para Modular Text Editor con Claude

Público: un asistente de IA (Claude / ChatGPT / etc.). Esta página es un conjunto de instrucciones autocontenido: entrégasela al asistente junto con la tarea («por favor, escríbeme un plugin que haga X») y el asistente debería producir una carpeta de plugin completa, correcta y funcional sin necesidad de leer el resto de la documentación.

Si eres una persona que intenta escribir un plugin a mano, empieza aquí: esta página está optimizada para el consumo por parte de una IA y se salta los ejemplos amables.


1. Qué estás produciendo

Una carpeta con exactamente dos archivos (más extras opcionales):

<plugin-id>/
├── plugin.json      # required
└── main.py          # required

Devuelve la carpeta como un bloque de código por archivo. El usuario o bien deja la carpeta en el directorio de plugins del editor, o bien comprime la carpeta y renombra el archivo como <name>.mteplugin y después lo instala mediante Preferencias → Complementos → Instalar un complemento.... Ambas vías necesitan un reinicio para cargar.

Los dos archivos se describen exhaustivamente más abajo. Sigue las reglas literalmente: el anfitrión realiza un sondeo estático y rechaza los plugins que las incumplen.


2. plugin.json — plantilla canónica

{
  "id":          "org.example.<short-name>",
  "name":        "<Human Display Name>",
  "version":     "1.0.0",
  "vendor":      "<Author or Team>",
  "description": "<One sentence about what it does.>",
  "apiVersion":  1,
  "entry":       "python",
  "module":      "main",
  "order":       900,
  "menus": [
    { "title": "Plugins", "barPriority": 570, "itemPriority": 100 }
  ]
}

Reglas estrictas

  1. "apiVersion" debe ser 1 (la versión actual del anfitrión).
  2. "entry" debe ser "python": ninguna otra cosa se encamina a este backend.
  3. "module" debe ser "main" salvo que haya un motivo explícito para darle otro nombre al módulo, y nunca una ruta con puntos (la fase 1 solo admite nombres de módulo planos).
  4. "id" debe tener estilo DNS inverso (org.<vendor>.<name>) y ser único a nivel global entre los plugins instalados del usuario. Si el usuario no indicó un fabricante, usa org.example..
  5. menus[] debe contener al menos una entrada cuyo title coincida con el segmento más a la izquierda de todas las rutas de menú que uses en main.py. Prefiere "Plugins" para cualquier cosa de cara al usuario que no pertenezca obviamente a un menú del núcleo.

Campos opcionales

  • "pyRequires": [] — una matriz de cadenas de requisitos de pip (p. ej. ["requests>=2.31"]). Cuando no está vacía, el editor aprovisiona un entorno virtual por plugin en el primer arranque (pip install, que necesita red esa única vez) y ejecuta el plugin bajo él. Prefiere la biblioteca estándar cuando baste (sin retraso de aprovisionamiento, funciona sin conexión); declara pyRequires cuando la tarea necesite de verdad un paquete de terceros, y avisa al usuario de que el primer arranque se detendrá para instalarlo.
  • "permissions": [] — declaraciones gruesas de capacidades (p. ej. ["network"]), que se muestran en modo de solo lectura en la página de Plugins. Declara "network" cuando el plugin haga llamadas hacia fuera; es un etiquetado honesto para el usuario, no una imposición.
  • "requires": [] — identificadores de OTROS plugins sin los que este no puede funcionar (no paquetes pip). Se inician primero; si falta alguno o está desactivado, este plugin no se inicia y la página de Plugins explica por qué. Déjalo vacío salvo que la tarea del usuario dependa de verdad de otro plugin.
  • "apiMinorVersion": 0 — la versión menor mínima de la API del editor que necesita el plugin. Déjala en 0 salvo que uses una llamada documentada como más reciente.

Valores que NO debes inventar

  • Cualquier campo que no esté listado arriba. El anfitrión ignora las claves desconocidas; añadirlas es un riesgo de mantenimiento.

3. main.py — esqueleto canónico

"""<Short description of what this plugin does.>"""
import mte  # noqa: F401  -- pulls in dataclasses for event payloads


def register(ctx):
    # Register commands, menu items, and event subscriptions here.
    # Everything below is illustrative -- delete what you don't need.

    @ctx.commands.command(id="<id>.<verb>",
                          title="<Human Label>",
                          shortcut="Ctrl+Alt+<letter>",   # optional
                          category="<Group>")             # optional
    def _handler():
        ctx.status.show("hello", timeout_ms=2000)
        ctx.log.info("<id>: handler ran")

    ctx.menus.add_item("Plugins/<Human Label>/<Item>", "<id>.<verb>")

Reglas estrictas

  1. register(ctx) DEBE ser una función de nivel superior. El sondeo estático exige una línea que empiece por def register( en la columna 0. Si la anidas dentro de una clase, la decoras en el ámbito del módulo o la haces async def, el plugin se rechaza en silencio en el momento de la detección.
  2. Importa desde mte cuando te suscribas a eventos: las dataclasses tipadas (mte.DocumentSaved, etc.) vienen de ese paquete. import mte al principio es seguro aunque no lo uses directamente; el # noqa: F401 de arriba evita el ruido del analizador de estilo.
  3. Nunca uses print(). La salida estándar del trabajador es el canal RPC; escribir en ella corrompe el protocolo. Usa ctx.log.info / warning / error.
  4. Nunca importes nada que no hayas declarado. Más allá de la biblioteca estándar y de mte, toda importación de terceros debe aparecer en el pyRequires de plugin.json (el editor la instala con pip en el entorno virtual del plugin en el primer arranque) o estar incluida dentro de la carpeta del plugin. Una importación no declarada lanza ModuleNotFoundError en la máquina del usuario.
  5. Los manejadores de comandos y de eventos pueden bloquear brevemente: se ejecutan en el grupo de hilos del trabajador, así que las llamadas síncronas a ctx.editor / ctx.settings / ctx.data_dir son legales. Pero las retrollamadas de los proveedores de autocompletado (@ctx.completions.provider(...)) NO DEBEN bloquear: se ejecutan en el bucle de eventos con un plazo de ~30 ms. Sin entrada/salida, sin llamadas a ctx.editor.*, solo caché. Construye los índices en otro sitio (p. ej. en document.opened) y responde desde un diccionario.
  6. No importes Qt, PySide, PyQt ni Scintilla. No están disponibles en el trabajador.
  7. Los identificadores de comando deben llevar el prefijo del espacio de nombres de tu plugin, para evitar colisiones con el núcleo o con otros plugins. <id>.<verb> como arriba; p. ej. wordcount.count, hello.sayHi.
  8. Las rutas de menú deben empezar por un menú de primer nivel que también hayas declarado en el menus[] de plugin.json. Convención: "Plugins/<Plugin Name>/<Verb>".

4. API completa — todo lo que hay en ctx

Tienes dieciocho servicios: commands, menus, events, log, status, editor, languages, settings, app_settings, document_settings, theme, diagnostics, compare, completions, docks, settings_page, ui y data_dir()/diagnostics_dir(). Todo lo que no esté listado aquí no existe: no inventes servicios. (Los entornos virtuales por plugin se declaran mediante pyRequires, no con un servicio de ctx; el sustrato de protocolo con flujo y cancelación todavía no tiene API de cara a los plugins.)

ctx.commands

@ctx.commands.command(*, id, title, shortcut="", category="",
                      requires_editor=False, requires_writable_editor=False)
def _handler(): ...

Decorador. Registra un comando y enlaza _handler (una función sin argumentos que devuelve None). Las excepciones en _handler las captura el trabajador y las escribe en stderr; no hacen fallar el editor.

Pon requires_editor cuando el comando lea el documento activo (búfer, selección, cursor) y requires_writable_editor cuando cambie el texto. El anfitrión atenúa entonces el comando allí donde no podría funcionar —en una pestaña sin documento (un diff o una vista de registro) y, con el indicador de escritura, en un documento de solo lectura— en lugar de dejar que el usuario invoque algo que no hace nada. El indicador de escritura implica al otro; deja ambos apagados para comandos que solo abren un diálogo o un panel.

ctx.menus

ctx.menus.add_item(path, command_id)                # -> handler_id (str)
ctx.menus.add_separator(path)                       # -> handler_id (str)
ctx.menus.set_item_checked(handler_id, checked)     # -> None
ctx.menus.set_item_enabled(handler_id, enabled)     # -> None

path es "Superior/Sub/…/Etiqueta". Los submenús intermedios se crean bajo demanda.

set_item_checked convierte el elemento en una entrada de menú marcable y fija su marca. Úsalo para conmutadores persistentes de encendido/apagado. La primera llamada lo promueve; las posteriores solo cambian la marca. No hace nada en silencio con identificadores desconocidos.

set_item_enabled atenúa el elemento. Úsalo cuando el comando necesite una condición previa, para que una entrada no disponible lo parezca en lugar de no hacer nada al pulsarla. Es idempotente; los elementos empiezan activados; no hace nada en silencio con identificadores desconocidos.

ctx.events

Diecisiete temas, cada uno con un decorador azucarado y una forma en bruto.

Azucarado Tema en bruto Carga útil
@ctx.events.on_document_opened document.opened mte.DocumentOpened(document: int, path: str)
@ctx.events.on_document_closed document.closed mte.DocumentClosed(document: int)
@ctx.events.on_document_saved document.saved mte.DocumentSaved(document: int)
@ctx.events.on_document_modified_changed document.modifiedChanged mte.DocumentModifiedChanged(document: int, modified: bool)
@ctx.events.on_document_encoding_changed document.encodingChanged mte.DocumentEncodingChanged(document: int, encoding: mte.DocumentEncoding)
@ctx.events.on_document_line_ending_changed document.lineEndingChanged mte.DocumentLineEndingChanged(document: int, ending: mte.LineEnding)
@ctx.events.on_text_changed text.changed mte.TextChanged(document, position, added_bytes, removed_bytes) — en cada edición, mantenlo trivial
@ctx.events.on_selection_changed selection.changed mte.SelectionChanged(document: int)
@ctx.events.on_active_document_changed document.activeChanged mte.ActiveDocumentChanged(document: int)
@ctx.events.on_settings_changed settings.changed mte.SettingsChanged(scope: str)
@ctx.events.on_theme_changed theme.changed mte.ThemeChanged(variant: str)
@ctx.events.on_workspace_root_changed workspace.rootChanged mte.WorkspaceRootChanged(root: str)
@ctx.events.on_workspace_files_changed workspace.filesChanged mte.WorkspaceFilesChanged(files: list[mte.WorkspaceFile])
@ctx.events.on_command_shortcut_changed command.shortcutChanged mte.CommandShortcutChanged(command_id: str, sequence: str)
@ctx.events.on_host_window_shown host.windowShown mte.HostWindowShown() — una vez por ejecución, después del primer fotograma
@ctx.events.on_app_about_to_quit app.aboutToQuit mte.AppAboutToQuit()
@ctx.events.on_margin_clicked margin.clicked mte.MarginClicked(document: int, line: int, margin: str, modifiers: int): un clic en la columna de marcadores; bits de modifiers: mte.MODIFIER_*

Forma en bruto:

handler_id = ctx.events.subscribe("<topic>", callback)
ctx.events.unsubscribe(handler_id)  # to unbind

No inventes temas nuevos: la forma en bruto los valida y lanza ValueError con cualquier cosa que no esté en la tabla.

ctx.log

ctx.log.trace  (str)
ctx.log.debug  (str)
ctx.log.info   (str)
ctx.log.warning(str)
ctx.log.error  (str)

Se lanza y se olvida. Las líneas llevan el prefijo [python:<plugin.id>] en el registro de diagnóstico del anfitrión.

ctx.status

ctx.status.show(message: str, timeout_ms: int = 0, level: str = "info")
ctx.status.clear()
seg = ctx.status.add_segment(id: str, priority: int = 100)   # persistent
seg.set_text(str); seg.set_visible(bool); seg.remove()

level debe ser "info" / "warning" / "error". timeout_ms=0 significa que el anfitrión elige un valor predeterminado. show() es para notificaciones pasajeras; todo lo que se quede en pantalla (un recuento en vivo, un indicador de modo) es un segmento: los dos nunca se pisan.

ctx.editor (fase 2)

Todos los métodos son síncronos: bloquean el hilo que llama. Está bien desde una retrollamada def de comando o de evento (el trabajador despacha los manejadores síncronos en un grupo de hilos); es ilegal desde manejadores async def y desde los proveedores de ctx.completions (que se ejecutan directamente en el bucle de eventos).

Los documentos se referencian mediante identificadores int opacos (0 = ningún documento).

doc     = ctx.editor.active_document()
docs    = ctx.editor.open_documents()
ctx.editor.set_active_document(doc)
path    = ctx.editor.file_path(doc)                 # str or None

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

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

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

new_doc = ctx.editor.open_file("/path/to/file")     # 0 on failure
here    = ctx.editor.open_file_at("/path/to/file", mte.EditorPosition(41, 0))  # open + caret + scroll + focus
ctx.editor.reveal_line(doc, 120)                    # scroll only; caret and focus stay
ok      = ctx.editor.save_document(doc)
closed  = ctx.editor.close_document(doc)   # prompts if modified; False = cancelled
dirty   = ctx.editor.is_modified(doc)

hit     = ctx.editor.find_in_range(doc, "TODO", 0,
             ctx.editor.length(doc),
             mte.SearchOptions(match_case=True))
new_end = ctx.editor.replace_target(doc, hit, "DONE",
             mte.SearchOptions())

style   = ctx.editor.indentation(doc)               # IndentationStyle
ctx.editor.set_indentation(doc,
    mte.IndentationStyle(use_tabs=False, width=2))
ctx.editor.set_wrap_indent(doc, "same")             # "left" | "same" | "indent"
lang    = ctx.editor.colorizer_id(doc)              # "python", "cpp", ...

mte.SearchOptions.mode: "normal" / "extended" / "regex". mte.SearchOptions.direction: "forward" / "backward".

ctx.settings / ctx.app_settings (fase 2)

Almacén tipado de clave/valor acotado por plugin (ctx.settings), más una vista de solo lectura de los ajustes de toda la aplicación (ctx.app_settings). El tipo se infiere del tipo de Python del valor predeterminado.

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

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

ctx.settings.contains("wrap")
ctx.settings.remove("stale")

# App-wide (read-only)
tw = ctx.app_settings.get("editor.tabWidth", 4)

ctx.app_settings.set/remove/sync lanzan RuntimeError. En tu propia lógica, comprueba bool antes que int al leer (en Python, isinstance(True, int) == True).

ctx.completions (fase 2)

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

Campos de req: document, prefix, position, language_id, manual. Tipos: KIND_KEYWORD / KIND_WORD / KIND_SYMBOL / KIND_SNIPPET / KIND_UNKNOWN. Devuelve list[CompletionItem], cadenas simples o diccionarios. La retrollamada DEBE devolver el control en ~30 ms o el anfitrión descarta tus resultados para esa pulsación: sin entrada/salida, sin ctx.editor.text(...), solo caché. Construye los índices en otro sitio (p. ej. en document.opened).

ctx.data_dir() / ctx.diagnostics_dir() (fase 2)

p = ctx.data_dir()   # pathlib.Path -- writable, host-created

Usa data_dir() para cachés e índices. diagnostics_dir() es de solo lectura (ahí escribe el informador de fallos).

ctx.docks — paneles acoplables declarativos (fase 3)

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

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="myplugin.panel",     # returned; use it for ctx.ui.get / ctx.ui.set
    title="My Plugin",
    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="myplugin.run")]),
    ]),
)

Construye la especificación de formulario con los ayudantes de mte.ui. El vocabulario de controles es fijo: Label / Button / Checkbox / LineEdit / NumberSpin / ComboBox / List dentro de Column / Row / Group.

  • binds="settings:<key>" — el valor del control se guarda automáticamente a través de ctx.settings (el mismo almacén que ctx.settings.set).
  • on_click="<cmd.id>" — solo en Button; invoca un comando registrado.
  • on_change=<handler> — asígnalo mediante ctx.docks.on_change(callback); se dispara con las ediciones del usuario.

Las retrollamadas de on_change se ejecutan en el bucle de eventos del trabajador (NO en el grupo de hilos). Mantenlas baratas: sin llamadas al editor, sin trabajo largo. Cambia un pequeño estado en memoria, escribe en el registro o dispara un comando; haz el trabajo pesado desde el comando.

Pasa initially_visible=False para un panel que no deba aparecer al arrancar y después muéstralo desde su comando con ctx.docks.set_panel_visible(panel_id, True) (que además lo trae al frente; es idempotente; los identificadores desconocidos se ignoran). ctx.docks.is_panel_visible(panel_id) dice si un panel está abierto (una pestaña en segundo plano cuenta). Varios paneles que van juntos: pasa el mismo tab_group="<plugin.id>/panels" y la misma area, y compartirán un único panel acoplable con pestañas.

ctx.line_markers — marcas en las líneas del documento

kind = ctx.line_markers.register_kind(glyph=mte.GLYPH_CIRCLE,
                                      glyph_color="<category id>",   # from ctx.theme
                                      line_tint="", z_order=0)       # RpcError when no slot is left
m = ctx.line_markers.add_marker(kind, doc, line)   # 0 = refused; follows edits
ctx.line_markers.line_of(m)                        # -1 = gone (document closed)
ctx.line_markers.markers(kind, doc)                # [mte.LineMarkerPlacement(id, line)]
ctx.line_markers.remove_marker(m)
claim = ctx.line_markers.claim_margin_click()      # stop bookmark toggling on margin clicks

Registra los tipos una sola vez en register(); el editor solo tiene unas pocas ranuras. Reclama el clic en el margen solo cuando el plugin le dé un significado: los clics llegan por @ctx.events.on_margin_clicked en cualquier caso.

ctx.languages — coloreado de sintaxis

ctx.languages.available()                 # list[LanguageInfo(id, name)]
ctx.languages.language_for(doc)           # "" when uncoloured
ctx.languages.set_language_for(doc, "json")   # "" clears; False = unknown id
ctx.languages.auto_detect_for(doc)
h = ctx.languages.register(id=, name=, lexer=, extensions=[...],
                           keywords=[{"group": ..., "words": ...}],
                           styles={"<LEXER STYLE NAME>": "comment", ...})
ctx.languages.unregister(h)               # restores what it displaced

lexer debe nombrar un analizador léxico del backend que ya exista; styles y keywords usan los nombres propios del analizador (la misma forma que los colorizers/*.json distribuidos), nunca identificadores numéricos.

ctx.document_settings / mte.keys

ctx.document_settings.get_bool(doc, key, default)   # .editorconfig-layered, read-only
ctx.document_settings.get_int / get_double / get_string / contains
ctx.app_settings.get(mte.keys.EDITOR_FONT_SIZE, mte.keys.DEFAULT_EDITOR_FONT_SIZE)

ctx.diagnostics — líneas onduladas

ctx.diagnostics.publish(doc, source: str, [mte.Diagnostic(start=, end=,
                        message=, severity=mte.SEVERITY_WARNING, code="")])
ctx.diagnostics.publish(doc, source, [])   # retract only your source
ctx.diagnostics.clear(doc)                 # clears EVERY producer's set

Envía cada vez el conjunto COMPLETO de tu source; sustituye al anterior. source es obligatorio.

ctx.theme — colores adaptables al tema para tu propia interfaz

h   = ctx.theme.register_category(id, display_label, default_light=0x..., default_dark=0x...)
rgb = ctx.theme.resolve_color(id)          # 0xRRGGBB; re-read on on_theme_changed
ctx.theme.has_override(id); ctx.theme.active_variant(); ctx.theme.categories()

ctx.compare — diff en paralelo

ctx.compare.open(mte.CompareSource(title, path=..., content=...),
                 mte.CompareSource(...), ignore_whitespace=False,
                 ignore_case=False, ignore_eol=False)   # -> bool

ctx.events.publish

Un plugin solo puede publicar workspace.rootChanged / workspace.filesChanged; cualquier otra cosa lanza ValueError. A todos los eventos del anfitrión salvo document.aboutToSave se puede uno suscribir (consulta la lista de decoradores); el evento nativo DocumentAboutToClose no tiene ningún tema en Python: usa document.closed.

ctx.settings_page — páginas de Preferencias declarativas (fase 3)

from mte.ui import Column, Checkbox, NumberSpin

ctx.settings_page.register(
    category="Plugins/My Plugin",     # slash-separated tree path
    title="My Plugin",
    form=Column([
        Checkbox(id="verbose", label="Verbose logging",
                 binds="settings:verbose"),
        NumberSpin(id="cap", default=100, min=1, max=10_000,
                   binds="settings:cap"),
    ]),
)

Los mismos constructores, la misma semántica de binds. Dos controles que comparten un binds="settings:<key>" se reflejan mutuamente en tiempo real: el panel acoplable y la página de Preferencias pueden tener ambos una casilla para el mismo ajuste, y cambiar una cambia la otra.

Salvedad de la fase 3

Los valores se guardan con avidez en cada edición del usuario: Cancelar no revierte lo que el usuario haya cambiado. Esto mejorará en un refinamiento futuro.

ctx.ui.get / ctx.ui.set — acceso en tiempo de ejecución a los controles de un panel (fase 3)

# Read the current value; returns str or None.
current = ctx.ui.get(panel_id, "needle")

# Write. Returns bool (False when panel/control unknown, or when the
# value can't be coerced to the target type).
ctx.ui.set(panel_id, "needle", "hello")
ctx.ui.set(panel_id, "cs", True)          # bool -> "true"/"false"

get devuelve el valor principal como cadena:

  • Label / Button / LineEdit / ComboBox → el texto
  • Checkbox → "true" / "false"
  • NumberSpin → el entero en decimal
  • List → el texto del elemento seleccionado en ese momento (cadena vacía si no hay ninguno)

Un set iniciado por el anfitrión es silencioso: NO vuelve a disparar el on_change del control, así que puedes actualizar tus propios widgets desde un manejador de comando sin bucles infinitos.


5. Ejemplo completo resuelto — el plugin wordcount

Cópialo literalmente y cambia el id, el name y los title para adaptarlo a tu caso. Ejercita todos los servicios de la fase 1.

wordcount/plugin.json
{
  "id":          "org.example.wordcount",
  "name":        "Word Count",
  "version":     "1.0.0",
  "vendor":      "Example, Inc.",
  "description": "Counts words the user has typed since launch and logs saves.",
  "apiVersion":  1,
  "entry":       "python",
  "module":      "main",
  "order":       900,
  "menus": [
    { "title": "Plugins", "barPriority": 570, "itemPriority": 100 }
  ]
}
wordcount/main.py
"""Reports a session word-count total via a menu command and logs saves."""
import mte  # noqa: F401

# Module-level state is fine -- one worker per plugin, one register() call.
_state = {"saves": 0}


def register(ctx):

    ctx.log.info("wordcount: register() ran")

    @ctx.commands.command(id="wordcount.report",
                          title="Report save count",
                          shortcut="Ctrl+Alt+W",
                          category="Text")
    def _report():
        n = _state["saves"]
        ctx.status.show(f"{n} save(s) this session", timeout_ms=2500)
        ctx.log.info(f"wordcount: user asked for count; {n} saves so far")

    ctx.menus.add_item("Plugins/Word Count/Report save count",
                       "wordcount.report")

    @ctx.events.on_document_saved
    def _saved(ev: mte.DocumentSaved):
        _state["saves"] += 1
        ctx.log.info(f"wordcount: doc {ev.document} saved -- total now {_state['saves']}")

    @ctx.events.on_app_about_to_quit
    def _quit(_ev):
        ctx.log.info(f"wordcount: shutting down after {_state['saves']} saves")

Flujo esperado del usuario: abrir el editor, guardar un archivo tres veces y después hacer clic en Plugins → Word Count → Report save count. La barra de estado muestra 3 save(s) this session. El registro anota cada paso.


6. Tareas habituales — recetas para copiar y pegar

Cada receta es un fragmento de solo cuerpo: pégalo dentro de def register(ctx):.

Añadir un comando de menú

@ctx.commands.command(id="mine.hello", title="Say Hi", shortcut="Ctrl+Alt+Y")
def _hi():
    ctx.status.show("hi")

ctx.menus.add_item("Plugins/Mine/Say Hi", "mine.hello")

Reaccionar al guardado

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

Avisar al salir

@ctx.events.on_app_about_to_quit
def _on_quit(_ev):
    ctx.log.warning("mine: last chance to persist state")

Registrar en un nivel concreto

ctx.log.trace  ("very fine")
ctx.log.debug  ("dev")
ctx.log.info   ("routine")
ctx.log.warning("odd")
ctx.log.error  ("wrong")

Separador de menú

ctx.menus.add_separator("Plugins/Mine")

Varios comandos relacionados

@ctx.commands.command(id="mine.a", title="Do A")
def _a(): ...

@ctx.commands.command(id="mine.b", title="Do B")
def _b(): ...

ctx.menus.add_item("Plugins/Mine/Do A", "mine.a")
ctx.menus.add_item("Plugins/Mine/Do B", "mine.b")

Conmutador persistente de encendido/apagado con marca visible

KEY = "verbose"

item = ctx.menus.add_item("View/Verbose Logging", "mine.toggleVerbose")
# Seed the mark from the persisted state so it matches on launch.
ctx.menus.set_item_checked(item, ctx.settings.get(KEY, False))

@ctx.commands.command(id="mine.toggleVerbose", title="Verbose Logging")
def _toggle():
    on = not ctx.settings.get(KEY, False)
    ctx.settings.set(KEY, on)
    ctx.menus.set_item_checked(item, on)

Panel lateral con una entrada de texto, una casilla y dos botones

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

def _on_needle(value): ctx.log.info(f"needle={value!r}")
needle_h = ctx.docks.on_change(_on_needle)

panel = ctx.docks.add_panel(
    id="mine.panel", title="Mine", area="right",
    form=Column([
        Label(text="Search:"),
        LineEdit(id="needle", placeholder="text...",
                 binds="settings:needle", on_change=needle_h),
        Checkbox(id="cs", label="Case", binds="settings:cs"),
        Row([Button(text="Run",  on_click="mine.run"),
             Button(text="Clear", on_click="mine.clear")]),
    ]),
)

@ctx.commands.command(id="mine.run", title="Run")
def _run():
    needle = ctx.ui.get(panel, "needle") or ""
    cs = ctx.ui.get(panel, "cs") == "true"
    ctx.status.show(f"searching for {needle!r} (case={cs})")

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

Página de Preferencias que refleja los ajustes de un panel

from mte.ui import Column, Checkbox

# Both controls bind to the SAME settings key -- toggling one
# flips the other in real time.
ctx.docks.add_panel(id="mine.panel", title="Mine", area="right",
    form=Column([Checkbox(id="cs", label="Case",
                          binds="settings:cs")]))

ctx.settings_page.register(category="Plugins/Mine", title="Mine",
    form=Column([Checkbox(id="cs", label="Case sensitive",
                          binds="settings:cs")]))

7. Antipatrones — NO hagas esto

Usar print() para diagnóstico

Mal:

def _handler():
    print("did the thing")   # <-- corrupts the RPC channel

Bien:

def _handler():
    ctx.log.info("did the thing")

register dentro de una clase

Mal:

class Plugin:
    def register(self, ctx):   # <-- static probe rejects this
        ...

Bien:

def register(ctx):
    ...

async def register

Mal:

async def register(ctx):   # <-- probe accepts but runtime never awaits
    ...

Bien:

def register(ctx):
    ...

Importaciones de terceros sin distribuir el wheel

Mal:

import requests   # <-- ImportError on most user machines

Bien (fase 1): cíñete a la biblioteca estándar. Si de verdad necesitas algo de terceros, distribuye el wheel dentro de la carpeta del plugin y amplía sys.path en las primeras líneas de main.py; consulta Empaquetado.

Importaciones de Qt

Mal:

from PySide6 import QtWidgets   # <-- not available; no Qt in the worker

Bien: nada de Qt en ningún sitio. Toda la interfaz que puedes aportar pasa por ctx.menus, ctx.status, ctx.diagnostics, ctx.compare y los paneles y páginas de ajustes declarativos. Nunca un widget en bruto.

Bloquear en un manejador

Mal:

def _handler():
    time.sleep(30)   # <-- stalls the worker for 30s
    result = requests.get("https://slow.example")

Bien (fase 1): delega en un hilo y comunícate mediante ctx.log:

def _handler():
    import threading
    def _work():
        time.sleep(30)
        ctx.log.info("done")
    threading.Thread(target=_work, daemon=True).start()
    ctx.status.show("started")

Ruta de menú cuyo segmento superior no está en plugin.json

Mal:

"menus": [ { "title": "Plugins", "barPriority": 570 } ]
ctx.menus.add_item("Tools/Mine/Do", "mine.do")   # <-- Tools ≠ Plugins

Bien: mantén el segmento de primer nivel de la ruta alineado con menus[]. Usa "Plugins/Mine/Do" en el caso anterior.

Guardar ctx para usarlo después

Mal:

_ctx = None

def register(ctx):
    global _ctx
    _ctx = ctx        # <-- fine for the lifetime of this worker,
                      #     but confusing and unnecessary

def some_other_function():
    _ctx.log.info("...")   # <-- called from where?

Bien: captura en el cierre que los usa los servicios que necesitas. register es donde se hace el cableado; todo lo demás son manejadores que ya tienen acceso por cierre.


8. Lista de comprobación de depuración para el usuario

Inclúyela al final de tu respuesta cuando produzcas un plugin, para que el usuario sepa qué comprobar si no funciona:

  1. Lanza el editor desde un intérprete de comandos en el que haya un intérprete de Python 3.9 o superior en el PATH (o en el que MTE_PYTHON apunte a uno).
  2. Busca PluginHost: Python plugin backend enabled; interpreter=… en el registro. Si no está, ve a Solución de problemas.
  3. Busca [python:<your.id>] register() ran (si seguiste el ejemplo de wordcount): demuestra que register se ejecutó.
  4. Si falta el elemento de menú, busca en el registro explicaciones del tipo PythonPluginBackend: skipping '<id>': ….
  5. Si al hacer clic en el menú no pasa nada, busca en el registro líneas [worker stderr] …: ahí acaban las trazas de Python.

9. Restricciones que hay que repetir al final de cada respuesta

Cuando produzcas un plugin, dile siempre al usuario:

  • Instálalo de cualquiera de las dos formas:
    • comprime el contenido de la carpeta como <name>.mteplugin y usa Preferencias → Complementos → Instalar un complemento... (valida el paquete antes de tocar nada), o
    • deja la carpeta en el directorio de plugins por usuario del editor (%LOCALAPPDATA%/MTE/Plugins/ en Windows; ~/Library/Application Support/MTE/Plugins/ en macOS; ~/.local/share/MTE/Plugins/ en Linux).
  • Reinicia el editor.
  • Define MTE_PYTHON si el registro dice «no Python interpreter found».
  • Informa de la línea de registro [python:<id>] … para que podamos confirmar que se ejecutó.

10. Fuente de verdad

Si alguna afirmación de esta página entra en conflicto con lo que el paquete cliente de Python implementa realmente, gana el paquete. Se distribuye junto al editor en <exe>/Python/mte/: lee ese código fuente antes de decidir un comportamiento. El lado de C++ del protocolo está en Components/PluginHost/Src/PyServiceBridge.cpp y la especificación del canal está en Python/PROTOCOL.md.