Pular para conteúdo

Criando um plugin Python do Modular Text Editor com o Claude

Público: um assistente de IA (Claude / ChatGPT / etc.). Esta página é um conjunto de instruções autocontido: entregue-a ao assistente junto com a tarefa ("por favor, escreva um plugin que faça X"), e o assistente deve produzir uma pasta de plugin completa, correta e funcional sem precisar ler o resto da documentação.

Se você é uma pessoa tentando escrever um plugin à mão, comece aqui — esta página é otimizada para consumo por IA e pula os exemplos amigáveis.


1. O que você está produzindo

Uma pasta com exatamente dois arquivos (mais extras opcionais):

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

Devolva a pasta como um bloco de código por arquivo. O usuário ou coloca a pasta no diretório de plugins do editor, ou compacta a pasta e renomeia o arquivo para <name>.mteplugin, instalando-o então por Preferências → Plug-ins → Instalar um plug-in.... As duas vias precisam de um reinício para carregar.

Os dois arquivos são descritos exaustivamente abaixo. Siga as regras literalmente — o host faz uma sondagem estática e rejeita plugins que as quebram.


2. plugin.json — modelo canônico

{
  "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 }
  ]
}

Regras rígidas

  1. O "apiVersion" precisa ser 1 (a versão atual do host).
  2. O "entry" precisa ser "python" — nada mais é encaminhado para este backend.
  3. O "module" precisa ser "main", a menos que haja um motivo explícito para nomear o módulo de outra forma — e nunca um caminho com pontos (a fase 1 suporta apenas nomes de módulo planos).
  4. O "id" precisa ser no estilo DNS reverso (org.<vendor>.<name>) e único globalmente entre os plugins instalados do usuário. Se o usuário não especificou um fabricante, use org.example..
  5. O menus[] precisa conter ao menos uma entrada cujo title coincida com o segmento mais à esquerda de todo caminho de menu que você usar no main.py. Prefira "Plugins" para qualquer coisa voltada ao usuário que não pertença obviamente a um menu do núcleo.

Campos opcionais

  • "pyRequires": [] — um array de cadeias de requisitos do pip (por exemplo, ["requests>=2.31"]). Quando não vazio, o editor provisiona um ambiente virtual por plugin na primeira inicialização (pip install — precisa de rede aquela única vez) e roda o plugin sob ele. Prefira a biblioteca padrão quando ela resolver (sem atraso de provisionamento, funciona offline); declare pyRequires quando a tarefa realmente precisar de um pacote de terceiros, e avise o usuário de que a primeira inicialização vai pausar para instalá-lo.
  • "permissions": [] — declarações grosseiras de capacidade (por exemplo, ["network"]), exibidas em modo somente leitura na página de Plugins. Declare "network" quando o plugin faz chamadas para fora; é rotulagem honesta para o usuário, não uma imposição.
  • "requires": [] — ids de OUTROS plugins sem os quais este não funciona (não pacotes pip). Eles iniciam primeiro; se algum estiver ausente ou desligado, este plugin não inicia e a página de Plugins diz por quê. Deixe vazio, a menos que a tarefa do usuário dependa de fato de outro plugin.
  • "apiMinorVersion": 0 — a menor versão minor da API do editor de que o plugin precisa. Deixe em 0, a menos que use uma chamada documentada como mais nova.

Valores que você NÃO deve inventar

  • Qualquer campo não listado acima. O host ignora chaves desconhecidas; acrescentá-las é um risco de manutenção.

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

Regras rígidas

  1. register(ctx) PRECISA ser uma função de nível superior. A sondagem estática exige uma linha começando por def register( na coluna 0. Se você a aninhar dentro de uma classe, decorá-la no escopo do módulo, ou torná-la async def, o plugin é rejeitado em silêncio na hora da descoberta.
  2. Importe de mte quando assinar eventos — as dataclasses tipadas (mte.DocumentSaved, etc.) vêm daquele pacote. Um import mte no topo é seguro mesmo que você não o use diretamente; o # noqa: F401 acima evita ruído do analisador de estilo.
  3. Nunca use print(). O stdout do trabalhador é o canal RPC; escrever nele corrompe o protocolo. Use ctx.log.info / warning / error.
  4. Nunca importe nada que você não declarou. Além da biblioteca padrão e de mte, toda importação de terceiros precisa aparecer no pyRequires do plugin.json (o editor a instala com pip no ambiente virtual do plugin na primeira inicialização) ou estar embutida dentro da pasta do plugin. Uma importação não declarada levanta ModuleNotFoundError na máquina do usuário.
  5. Manipuladores de comando/evento podem bloquear brevemente — eles rodam no pool de threads do trabalhador, então chamadas síncronas a ctx.editor / ctx.settings / ctx.data_dir são legais. Mas retrochamadas de provedor de autocompletar (@ctx.completions.provider(...)) NÃO PODEM bloquear: elas rodam no laço de eventos com um prazo de ~30 ms. Sem E/S, sem chamadas a ctx.editor.*, apenas cache. Construa índices em outro lugar (por exemplo, em document.opened) e responda a partir de um dicionário.
  6. Não importe Qt, PySide, PyQt ou Scintilla. Eles não estão disponíveis no trabalhador.
  7. Os ids de comando precisam levar o prefixo do espaço de nomes do seu plugin, para evitar colisões com o núcleo / outros plugins. <id>.<verb>, como acima; por exemplo, wordcount.count, hello.sayHi.
  8. Os caminhos de menu precisam começar com um menu de primeiro nível que você também declarou no menus[] do plugin.json. Convenção: "Plugins/<Plugin Name>/<Verb>".

4. API completa — tudo o que há em ctx

Você tem dezoito serviços: commands, menus, events, log, status, editor, languages, settings, app_settings, document_settings, theme, diagnostics, compare, completions, docks, settings_page, ui e data_dir()/diagnostics_dir(). Qualquer coisa não listada aqui não existe — não invente serviços. (Ambientes virtuais por plugin são declarados via pyRequires, não por um serviço de ctx; o substrato de protocolo com fluxo/cancelamento ainda não tem API voltada aos plugins.)

ctx.commands

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

Decorador. Registra um comando e vincula _handler (uma função sem argumentos que devolve None). Exceções em _handler são capturadas pelo trabalhador e escritas em stderr; elas não derrubam o editor.

Defina requires_editor quando o comando lê o documento ativo (buffer, seleção, cursor) e requires_writable_editor quando ele altera o texto. O host então esmaece o comando onde ele não poderia funcionar — em uma aba sem documento (um diff ou uma visualização de log) e, para o sinalizador de escrita, em um documento somente leitura — em vez de deixar o usuário invocar algo que silenciosamente não faz nada. O sinalizador de escrita implica o outro; deixe os dois desligados para comandos que apenas abrem uma caixa de diálogo ou um painel.

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 é "Topo/Sub/…/Rótulo". Submenus intermediários são criados sob demanda.

O set_item_checked transforma o item em uma entrada de menu marcável e define a marca dele. Use-o para alternâncias persistentes de liga/desliga. A primeira chamada promove; as seguintes apenas invertem. Não faz nada em silêncio com ids desconhecidos.

O set_item_enabled esmaece o item. Use-o quando o comando precisa de uma condição prévia, para que uma entrada indisponível pareça indisponível em vez de não fazer nada quando clicada. Idempotente; os itens começam habilitados; não faz nada em silêncio com ids desconhecidos.

ctx.events

Dezessete tópicos, cada um com um decorador açucarado e uma forma bruta.

Açucarado Tópico 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) — toda edição, mantenha 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() — uma vez por execução, depois do primeiro quadro
@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) — um clique na coluna de marcadores; bits de modifiers: mte.MODIFIER_*

Forma bruta:

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

Não invente tópicos novos — a forma bruta os valida e levanta ValueError para qualquer coisa fora da tabela.

ctx.log

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

Dispare-e-esqueça. As linhas recebem o prefixo [python:<plugin.id>] no log de diagnóstico do host.

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

O level precisa ser "info" / "warning" / "error". timeout_ms=0 significa que o host escolhe um padrão. O show() é para notificações passageiras; qualquer coisa que fique na tela (uma contagem ao vivo, um sinalizador de modo) é um segmento — os dois nunca se atropelam.

ctx.editor (fase 2)

Todo método é síncrono: ele bloqueia a thread chamadora. Tudo bem a partir de uma retrochamada def de comando/evento (o trabalhador despacha manipuladores síncronos em um pool de threads); ilegal a partir de manipuladores async def e de provedores de ctx.completions (que rodam diretamente no laço de eventos).

Os documentos são referenciados por ids int opacos (0 = nenhum 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)

Armazenamento tipado de chave/valor restrito por plugin (ctx.settings), mais uma visão somente leitura das configurações de todo o aplicativo (ctx.app_settings). O tipo é inferido do tipo Python do padrão.

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 levantam RuntimeError. Na sua própria lógica, verifique bool antes de int ao ler (em 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. Devolva list[CompletionItem], cadeias simples ou dicionários. A retrochamada PRECISA retornar em ~30 ms ou o host descarta os seus resultados para aquela tecla — sem E/S, sem ctx.editor.text(...), apenas cache. Construa índices em outro lugar (por exemplo, em document.opened).

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

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

Use data_dir() para caches e índices. O diagnostics_dir() é somente leitura (o relator de falhas grava ali).

ctx.docks — painéis acopláveis 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")]),
    ]),
)

Construa a especificação de formulário com os auxiliares de mte.ui. O vocabulário de controles é fixo: Label / Button / Checkbox / LineEdit / NumberSpin / ComboBox / List dentro de Column / Row / Group.

  • binds="settings:<key>" — o valor do controle persiste automaticamente por ctx.settings (o mesmo armazenamento de ctx.settings.set).
  • on_click="<cmd.id>" — apenas em Button; invoca um comando registrado.
  • on_change=<handler> — aloque-o via ctx.docks.on_change(callback); dispara nas edições do usuário.

As retrochamadas de on_change rodam no laço de eventos do trabalhador (NÃO no pool de threads). Mantenha-as baratas — sem chamadas ao editor, sem trabalho longo. Altere um pequeno estado em memória, registre no log, ou dispare um comando; faça o trabalho pesado a partir do comando.

Passe initially_visible=False para um painel que não deve aparecer na inicialização e revele-o então, a partir do comando dele, com ctx.docks.set_panel_visible(panel_id, True) (que também o traz para a frente; idempotente; ids desconhecidos ignorados). ctx.docks.is_panel_visible(panel_id) diz se um painel está aberto (uma aba em segundo plano conta). Vários painéis que andam juntos: passe o mesmo tab_group="<plugin.id>/panels" e a mesma area, e eles compartilham um único painel acoplável em abas.

ctx.line_markers — marcas nas linhas do 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

Registre os tipos uma única vez em register(); o editor tem só poucos espaços. Só reivindique o clique na margem quando o plugin lhe der um significado — os cliques chegam por @ctx.events.on_margin_clicked de qualquer forma.

ctx.languages — coloração de sintaxe

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

O lexer precisa nomear um analisador léxico de backend existente; styles e keywords usam os nomes do próprio analisador (a mesma forma dos colorizers/*.json distribuídos), nunca ids 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 — traços ondulados

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

Envie o conjunto COMPLETO da sua source toda vez; ele substitui o anterior. A source é obrigatória.

ctx.theme — cores adaptáveis ao tema para a sua interface

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 lado a lado

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

ctx.events.publish

Apenas workspace.rootChanged / workspace.filesChanged podem ser publicados por um plugin; qualquer outra coisa levanta ValueError. Todo evento do host, exceto document.aboutToSave, pode ser assinado (veja a lista de decoradores); o evento nativo DocumentAboutToClose não tem tópico em Python algum — use document.closed.

ctx.settings_page — páginas de Preferências 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"),
    ]),
)

Os mesmos construtores, a mesma semântica de binds. Dois controles que compartilham um binds="settings:<key>" se espelham em tempo real — o painel acoplável e a página de Preferências podem ter ambos uma caixa de seleção para a mesma configuração, e alternar uma inverte a outra.

Ressalva da fase 3

Os valores persistem avidamente a cada edição do usuário — Cancelar não reverte o que o usuário alterou. Isso vai melhorar em um refinamento futuro.

ctx.ui.get / ctx.ui.set — acesso em tempo de execução aos controles de painel (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"

O get devolve o valor principal como cadeia:

  • Label / Button / LineEdit / ComboBox → texto
  • Checkbox → "true" / "false"
  • NumberSpin → inteiro em decimal
  • List → texto do item selecionado no momento (cadeia vazia se nenhum)

Um set iniciado pelo host é silencioso: ele NÃO dispara de novo o on_change do controle, então você pode atualizar os seus próprios widgets a partir de um manipulador de comando sem laços infinitos.


5. Exemplo completo resolvido — plugin wordcount

Copie isto literalmente e mude o id, o name e os title para o seu caso. Ele exercita todos os serviços da 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")

Fluxo esperado do usuário: abrir o editor, salvar um arquivo três vezes e então clicar em Plugins → Word Count → Report save count. A barra de status exibe 3 save(s) this session. O log registra cada passo.


6. Tarefas comuns — receitas para copiar e colar

Cada receita é um trecho só de corpo: cole dentro de def register(ctx):.

Acrescentar um comando de menu

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

Reagir ao salvamento

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

Avisar na saída

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

Registrar em um nível específico

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

Separador de menu

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

Vários 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")

Alternância persistente de liga/desliga com marca visível

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)

Painel lateral com uma entrada de texto, uma caixa de seleção e dois botões

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 Preferências espelhando as configurações de um painel

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. Antipadrões — NÃO faça isto

Errado:

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

Certo:

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

register dentro de uma classe

Errado:

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

Certo:

def register(ctx):
    ...

async def register

Errado:

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

Certo:

def register(ctx):
    ...

Importações de terceiros sem distribuir o wheel

Errado:

import requests   # <-- ImportError on most user machines

Certo (fase 1): fique na biblioteca padrão. Se você realmente precisa de algo de terceiros, distribua o wheel dentro da pasta do plugin e estenda o sys.path nas primeiras linhas do main.py; veja Empacotamento.

Importações do Qt

Errado:

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

Certo: nada de Qt em lugar algum. Toda a interface que você pode contribuir passa por ctx.menus, ctx.status, ctx.diagnostics, ctx.compare e os painéis e páginas de configurações declarativos. Nunca um widget cru.

Bloquear em um manipulador

Errado:

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

Certo (fase 1): delegue a uma thread e comunique-se por 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")

Caminho de menu cujo segmento do topo não está no plugin.json

Errado:

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

Certo: mantenha o segmento de primeiro nível do caminho alinhado com menus[]. Use "Plugins/Mine/Do" no caso acima.

Guardar ctx para uso posterior

Errado:

_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?

Certo: capture no closure que os usa os serviços de que você precisa. O register é onde a fiação acontece; todo o resto é um manipulador que já tem acesso por closure.


8. Lista de verificação de depuração para o usuário

Inclua isto ao final da sua resposta quando produzir um plugin, para que o usuário saiba o que conferir se ele não funcionar:

  1. Inicie o editor a partir de um shell em que um interpretador Python 3.9+ esteja no PATH (ou em que o MTE_PYTHON aponte para um).
  2. Procure PluginHost: Python plugin backend enabled; interpreter=… no log. Se estiver faltando, vá para Solução de problemas.
  3. Procure [python:<your.id>] register() ran (se você seguiu o exemplo do wordcount) — prova que o register foi executado.
  4. Se o item de menu estiver faltando, procure no log explicações do tipo PythonPluginBackend: skipping '<id>': ….
  5. Se clicar no menu não faz nada, procure no log linhas [worker stderr] … — os rastreamentos do Python param ali.

9. Restrições a reiterar ao final de toda resposta

Quando você produzir um plugin, sempre diga ao usuário:

  • Instale-o de uma das duas formas:
    • compacte o conteúdo da pasta como <name>.mteplugin e use Preferências → Plug-ins → Instalar um plug-in... (valida o pacote antes de tocar em qualquer coisa), ou
    • coloque a pasta no diretório de plugins por usuário do editor (%LOCALAPPDATA%/MTE/Plugins/ no Windows; ~/Library/Application Support/MTE/Plugins/ no macOS; ~/.local/share/MTE/Plugins/ no Linux).
  • Reinicie o editor.
  • Defina o MTE_PYTHON se o log disser "no Python interpreter found".
  • Informe a linha de log [python:<id>] … para que possamos confirmar que ele rodou.

10. Fonte de verdade

Se qualquer afirmação desta página conflitar com o que o pacote cliente Python de fato implementa, o pacote vence. Ele é distribuído ao lado do editor em <exe>/Python/mte/ — leia aquele código-fonte antes de decidir um comportamento. O lado C++ do protocolo está em Components/PluginHost/Src/PyServiceBridge.cpp e a especificação do canal está em Python/PROTOCOL.md.