Przejdź do treści

Pisanie wtyczki Pythona dla Modular Text Editor z Claude

Odbiorca: asystent AI (Claude / ChatGPT / itp.). Ta strona to samowystarczalny zestaw instrukcji: przekaż ją asystentowi razem z zadaniem („napisz mi wtyczkę, która robi X"), a asystent powinien wyprodukować kompletny, poprawny, działający folder wtyczki bez czytania reszty dokumentacji.

Jeśli jesteś człowiekiem piszącym wtyczkę ręcznie, zacznij tutaj — ta strona jest zoptymalizowana pod konsumpcję przez AI i pomija przyjazne przykłady.


1. Co produkujesz

Folder z dokładnie dwoma plikami (plus opcjonalne dodatki):

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

Zwróć folder jako blok kodu per plik. Użytkownik albo wrzuca folder do katalogu wtyczek edytora, albo zipuje folder, zmienia nazwę archiwum na <name>.mteplugin i instaluje przez Preferencje → Wtyczki → Zainstaluj wtyczkę.... Obie ścieżki wymagają restartu do załadowania.

Oba pliki są opisane wyczerpująco niżej. Trzymaj się reguł dosłownie — host wykonuje statyczną sondę i odrzuca wtyczki, które je łamią.


2. plugin.json — szablon kanoniczny

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

Twarde reguły

  1. "apiVersion" musi być 1 (bieżąca wersja hosta).
  2. "entry" musi być "python" — nic innego nie kieruje do tego backendu.
  3. "module" musi być "main", chyba że istnieje jawny powód, by nazwać moduł inaczej — i nigdy ścieżka z kropkami (Faza 1 wspiera tylko płaskie nazwy modułów).
  4. "id" musi być w stylu odwróconego DNS (org.<vendor>.<name>) i globalnie unikalne wśród wtyczek użytkownika. Jeśli użytkownik nie podał vendora, użyj org.example..
  5. menus[] musi zawierać co najmniej jeden wpis, którego title odpowiada skrajnie lewemu segmentowi każdej ścieżki menu użytej w main.py. Dla rzeczy skierowanych do użytkownika, które nie pasują oczywiście do menu rdzenia, preferuj "Plugins".

Pola opcjonalne

  • "pyRequires": [] — tablica łańcuchów wymagań pip (np. ["requests>=2.31"]). Gdy niepusta, edytor przygotowuje venv per wtyczka przy pierwszym starcie (pip install — ten jeden raz potrzebna sieć) i uruchamia wtyczkę pod nim. Preferuj bibliotekę standardową, gdy wystarcza (brak opóźnienia provisioning-u, działa offline); deklaruj pyRequires, gdy zadanie naprawdę wymaga pakietu zewnętrznego, i powiedz użytkownikowi, że pierwszy start zatrzyma się na instalację.
  • "permissions": [] — zgrubne deklaracje możliwości (np. ["network"]), pokazywane tylko do odczytu na stronie Wtyczek. Deklaruj "network", gdy wtyczka wykonuje połączenia wychodzące; to uczciwa etykieta dla użytkownika, nie egzekwowanie.
  • "requires": [] — identyfikatory INNYCH wtyczek, bez których ta nie może działać (nie pakiety pip). Startują pierwsze; jeśli którejś brakuje albo jest wyłączona, ta wtyczka nie startuje, a strona Wtyczek mówi dlaczego. Zostaw pustą tablicę, chyba że zadanie użytkownika naprawdę zależy od innej wtyczki.
  • "apiMinorVersion": 0 — najniższa wersja minor API edytora, jakiej potrzebuje wtyczka. Zostaw 0, chyba że używasz wywołania opisanego jako nowsze.

Wartości, których NIE wymyślaj

  • Żadnych pól spoza listy powyżej. Host ignoruje nieznane klucze; dodawanie ich to ryzyko utrzymaniowe.

3. main.py — szkielet kanoniczny

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

Twarde reguły

  1. register(ctx) MUSI być funkcją najwyższego poziomu. Statyczna sonda wymaga linii zaczynającej się od def register( w kolumnie 0. Zagnieżdżenie w klasie, dekorowanie na poziomie modułu albo async def powoduje ciche odrzucenie wtyczki przy wykrywaniu.
  2. Importuj z mte, gdy subskrybujesz zdarzenia — typowane dataclassy (mte.DocumentSaved itd.) pochodzą z tego pakietu. import mte na górze jest bezpieczny nawet nieużywany wprost; # noqa: F401 wyżej ucisza linter.
  3. Nigdy print(). Stdout workera to kanał RPC; pisanie do niego psuje protokół. Używaj ctx.log.info / warning / error.
  4. Nigdy nie importuj niczego niezadeklarowanego. Poza biblioteką standardową i mte każdy import zewnętrzny musi wystąpić w pyRequires w plugin.json (edytor pip-instaluje go do venv wtyczki przy pierwszym starcie) albo być vendorowany w folderze wtyczki. Niezadeklarowany import rzuca ModuleNotFoundError na maszynie użytkownika.
  5. Handlery poleceń / zdarzeń mogą krótko blokować — działają w puli wątków workera, więc synchroniczne wywołania ctx.editor / ctx.settings / ctx.data_dir są legalne. Ale callbacki dostawców uzupełnień (@ctx.completions.provider(...)) NIE MOGĄ blokować: działają na pętli zdarzeń z deadlinem ~30 ms. Bez I/O, bez wywołań ctx.editor.*, tylko cache. Indeksy buduj gdzie indziej (np. na document.opened) i odpowiadaj z dicta.
  6. Nie importuj Qt, PySide, PyQt ani Scintilli. Nie są dostępne w workerze.
  7. Identyfikatory poleceń muszą mieć prefiks przestrzeni nazw Twojej wtyczki, aby uniknąć kolizji z rdzeniem / innymi wtyczkami. <id>.<verb> wyżej; np. wordcount.count, hello.sayHi.
  8. Ścieżki menu muszą zaczynać się od menu najwyższego poziomu zadeklarowanego też w menus[] w plugin.json. Konwencja: "Plugins/<Plugin Name>/<Verb>".

4. Kompletne API — wszystko na ctx

Masz osiemnaście usług: commands, menus, events, log, status, editor, languages, settings, app_settings, document_settings, theme, diagnostics, compare, completions, docks, settings_page, ui oraz data_dir()/diagnostics_dir(). Cokolwiek niewymienione tutaj nie istnieje — nie wymyślaj usług. (Venvy per wtyczka deklaruje się przez pyRequires, nie usługę ctx; substrat protokołu streaming/cancel nie ma jeszcze API dla wtyczek.)

ctx.commands

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

Dekorator. Rejestruje polecenie i wiąże _handler (funkcję bezargumentową zwracającą None). Wyjątki w _handler łapie worker i pisze na stderr; nie wywracają edytora.

Ustaw requires_editor, gdy polecenie czyta aktywny dokument (bufor, zaznaczenie, kursor), a requires_writable_editor, gdy zmienia tekst. Host wyszarzy wtedy polecenie tam, gdzie nie mogłoby zadziałać — na karcie bez dokumentu (widok różnic lub logu), a przy fladze „writable” także na dokumencie tylko do odczytu — zamiast pozwolić użytkownikowi wywołać coś, co po cichu nic nie zrobi. Flaga „writable” implikuje tę pierwszą; dla poleceń, które tylko otwierają okno lub panel, zostaw obie wyłączone.

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 to "Top/Sub/…/Etykieta". Pośrednie podmenu powstają na żądanie.

set_item_checked zamienia pozycję w zaznaczalny wpis menu i ustawia znacznik. Używaj do trwałych przełączników wł./wył. Pierwsze wywołanie promuje; kolejne tylko przełączają. Cichy no-op na nieznanych id.

set_item_enabled wygasza pozycję. Używaj, gdy polecenie wymaga spełnionego warunku, aby niedostępny wpis wyglądał na niedostępny, zamiast nic nie robić po kliknięciu. Idempotentne; pozycje startują jako aktywne; cichy no-op na nieznanych id.

ctx.events

Siedemnaście tematów, każdy z dekoratorem-cukrem i formą surową.

Cukier Surowy temat Ładunek
@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) — każda edycja, trzymaj trywialny
@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() — raz na uruchomienie, po pierwszej klatce
@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) — kliknięcie w kolumnie zakładek; bity modifiers to mte.MODIFIER_*

Forma surowa:

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

Nie wymyślaj nowych tematów — forma surowa je waliduje i rzuca ValueError na wszystko spoza tabeli.

ctx.log

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

Wyślij i zapomnij. Linie mają prefiks [python:<plugin.id>] w logu diagnostycznym hosta.

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 musi być "info" / "warning" / "error". timeout_ms=0 znaczy, że host wybiera domyślny. show() służy do tymczasowych powiadomień; cokolwiek ma zostać na ekranie (licznik na żywo, flaga trybu) to segment — te dwa nigdy się nie nadpisują.

ctx.editor (Faza 2)

Każda metoda jest synchroniczna: blokuje wątek wywołujący. W porządku z callbacku polecenia / zdarzenia def (worker odpala handlery sync na puli wątków); nielegalne z handlerów async def i z dostawców ctx.completions (działających wprost na pętli zdarzeń).

Dokumenty adresują nieprzezroczyste identyfikatory int (0 = brak dokumentu).

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

Typowany magazyn klucz/wartość per wtyczka (ctx.settings) plus widok tylko do odczytu ustawień całej aplikacji (ctx.app_settings). Typ wnioskowany z pythonowego typu wartości domyślnej.

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 rzucają RuntimeError. We własnej logice odczytu sprawdzaj bool przed int (w Pythonie isinstance(True, int) == True).

ctx.completions (Faza 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)]

Pola req: document, prefix, position, language_id, manual. Rodzaje: KIND_KEYWORD / KIND_WORD / KIND_SYMBOL / KIND_SNIPPET / KIND_UNKNOWN. Zwracaj list[CompletionItem], gołe łańcuchy albo dicty. Callback MUSI wrócić w ~30 ms, inaczej host porzuca Twoje wyniki dla tego naciśnięcia — bez I/O, bez ctx.editor.text(...), tylko cache. Indeksy buduj gdzie indziej (np. na document.opened).

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

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

Używaj data_dir() na cache i indeksy. diagnostics_dir() jest tylko do odczytu (pisze tam raporter awarii).

ctx.docks — deklaratywne panele dokowane (Faza 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")]),
    ]),
)

Specyfikację formularza buduj pomocnikami mte.ui. Słownik kontrolek jest stały: Label / Button / Checkbox / LineEdit / NumberSpin / ComboBox / List w Column / Row / Group.

  • binds="settings:<key>" — wartość kontrolki utrwala się automatycznie przez ctx.settings (ten sam magazyn co ctx.settings.set).
  • on_click="<cmd.id>" — tylko Button; wywołuje zarejestrowane polecenie.
  • on_change=<handler> — alokuj przez ctx.docks.on_change(callback); odpala się na edycjach użytkownika.

Callbacki on_change działają na pętli zdarzeń workera (NIE na puli wątków). Niech będą tanie — bez wywołań edytora, bez długiej pracy. Zmień mały stan w pamięci, zaloguj albo odpal polecenie; ciężką pracę rób z polecenia.

Podaj initially_visible=False dla panelu, który nie ma wyskakiwać przy starcie, a potem pokaż go z jego polecenia przez ctx.docks.set_panel_visible(panel_id, True) (podnosi też na wierzch; idempotentne; nieznane id ignorowane). ctx.docks.is_panel_visible(panel_id) mówi, czy panel jest otwarty (karta w tle się liczy). Kilka paneli, które należą do siebie: podaj ten sam tab_group="<plugin.id>/panels" i ten sam area, a będą dzielić jeden dok z kartami.

ctx.line_markers — oznaczenia na wierszach dokumentu

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

Rejestruj rodzaje raz, w register(); edytor ma tylko kilka miejsc. Przejmuj kliknięcie marginesu tylko wtedy, gdy wtyczka nadaje mu znaczenie — kliknięcia i tak przychodzą przez @ctx.events.on_margin_clicked.

ctx.languages — kolorowanie składni

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 musi wskazywać istniejący lekser backendu; styles i keywords używają nazw leksera (ten sam kształt co dostarczone colorizers/*.json), nigdy id liczbowych.

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 — podkreślenia

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

Za każdym razem wysyłaj PEŁNY zestaw dla swojego source; zastępuje poprzedni. source jest wymagane.

ctx.theme — kolory z motywu dla własnego UI

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 — porównanie obok siebie

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

ctx.events.publish

Wtyczka może publikować tylko workspace.rootChanged / workspace.filesChanged; cokolwiek innego rzuca ValueError. Subskrybować można każde zdarzenie hosta poza document.aboutToSave (zobacz listę dekoratorów); natywne zdarzenie DocumentAboutToClose w ogóle nie ma tematu w Pythonie — używaj document.closed.

ctx.settings_page — deklaratywne strony Preferencji (Faza 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"),
    ]),
)

Te same kreatory, ta sama semantyka binds. Dwie kontrolki dzielące binds="settings:<key>" odzwierciedlają się w czasie rzeczywistym — panel dokowany i strona Preferencji mogą obie trzymać checkbox tego samego ustawienia, a przełączenie jednego przełącza drugi.

Zastrzeżenie Fazy 3

Wartości utrwalają się na bieżąco przy każdej edycji użytkownika — Anuluj nie wycofuje tego, co zmienił. Poprawi się to w przyszłym udoskonaleniu.

ctx.ui.get / ctx.ui.set — dostęp runtime do kontrolek panelu (Faza 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 zwraca wartość główną jako łańcuch:

  • Label / Button / LineEdit / ComboBox → tekst
  • Checkbox → "true" / "false"
  • NumberSpin → liczba dziesiętnie
  • List → tekst wybranej pozycji (pusty łańcuch, gdy brak)

set sterowany przez hosta jest cichy: NIE odpala ponownie on_change kontrolki, więc możesz aktualizować własne widżety z handlera polecenia bez nieskończonych pętli.


5. Pełny przykład — wtyczka wordcount

Skopiuj dosłownie i zmień id, name i title pod swój przypadek. Ćwiczy każdą usługę Fazy 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")

Oczekiwany przepływ: otwórz edytor, zapisz plik trzy razy, potem kliknij Plugins → Word Count → Report save count. Pasek stanu pokaże 3 save(s) this session. Log rejestruje każdy krok.


6. Częste zadania — przepisy kopiuj-wklej

Każdy przepis to snippet samego ciała: wklej wewnątrz def register(ctx):.

Dodaj polecenie w 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")

Reaguj na zapis

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

Ostrzeż przy wyjściu

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

Loguj na konkretnym poziomie

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

Separator menu

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

Kilka powiązanych poleceń

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

Trwały przełącznik wł./wył. z widocznym znacznikiem

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 boczny z polem tekstowym, checkboxem i dwoma przyciskami

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

Strona Preferencji lustrzana do ustawień doku

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. Antywzorce — NIE rób tego

Źle:

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

Dobrze:

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

register w klasie

Źle:

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

Dobrze:

def register(ctx):
    ...

async def register

Źle:

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

Dobrze:

def register(ctx):
    ...

Importy zewnętrzne bez dostarczenia pakietu

Źle:

import requests   # <-- ImportError on most user machines

Dobrze (Faza 1): trzymaj się biblioteki standardowej. Jeśli naprawdę potrzebujesz czegoś zewnętrznego, dostarcz wheel w folderze wtyczki i rozszerz sys.path w pierwszych liniach main.py; zobacz Pakowanie.

Importy Qt

Źle:

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

Dobrze: żadnego Qt. Całe UI, jakie możesz wnieść, idzie przez ctx.menus, ctx.status, ctx.diagnostics, ctx.compare i deklaratywne panele dok/ustawień. Nigdy surowy widżet.

Blokowanie w handlerze

Źle:

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

Dobrze (Faza 1): oddeleguj do wątku, komunikuj przez 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")

Ścieżka menu, której górny segment nie jest w plugin.json

Źle:

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

Dobrze: trzymaj górny segment ścieżki zgodny z menus[]. Powyżej użyj "Plugins/Mine/Do".

Przechowywanie ctx na później

Źle:

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

Dobrze: przechwyć potrzebne usługi w domknięciu, które ich używa. register to miejsce okablowania; wszystko inne to handler, który już ma dostęp przez domknięcie.


8. Checklist debugowania dla użytkownika

Dołącz to na końcu odpowiedzi, gdy produkujesz wtyczkę, aby użytkownik wiedział, co sprawdzić, gdy nie działa:

  1. Uruchom edytor z powłoki, w której interpreter Pythona 3.9+ jest w PATH (albo MTE_PYTHON na niego wskazuje).
  2. Poszukaj w logu PluginHost: Python plugin backend enabled; interpreter=…. Jeśli brak, skocz do Rozwiązywania problemów.
  3. Poszukaj [python:<your.id>] register() ran (jeśli poszedłeś za przykładem wordcount) — dowód, że register się wykonał.
  4. Jeśli brakuje pozycji menu, sprawdź w logu wyjaśnienia PythonPluginBackend: skipping '<id>': ….
  5. Jeśli klik w menu nic nie robi, sprawdź w logu linie [worker stderr] … — tam lądują pythonowe tracebacki.

9. Ograniczenia do powtórzenia na końcu każdej odpowiedzi

Gdy produkujesz wtyczkę, zawsze powiedz użytkownikowi:

  • Zainstaluj na jeden z dwóch sposobów:
    • zzipuj zawartość folderu jako <name>.mteplugin i użyj Preferencje → Wtyczki → Zainstaluj wtyczkę... (waliduje pakiet, zanim czegokolwiek dotknie), albo
    • wrzuć folder do katalogu wtyczek użytkownika (%LOCALAPPDATA%/MTE/Plugins/ na Windows; ~/Library/Application Support/MTE/Plugins/ na macOS; ~/.local/share/MTE/Plugins/ na Linuksie).
  • Zrestartuj edytor.
  • Ustaw MTE_PYTHON, jeśli log mówi „no Python interpreter found".
  • Odeślij linię logu [python:<id>] …, abyśmy potwierdzili działanie.

10. Źródło prawdy

Jeśli cokolwiek na tej stronie kłóci się z tym, co faktycznie implementuje pakiet klienta Pythona, wygrywa pakiet. Leży obok edytora w <exe>/Python/mte/ — przeczytaj to źródło przed rozstrzygnięciem zachowania. Strona C++ protokołu jest w Components/PluginHost/Src/PyServiceBridge.cpp, a specyfikacja przewodowa w Python/PROTOCOL.md.