Plugin API — overview¶
All PluginApi types live in namespace MTE::plugin. The public headers may not
include any Qt, Scintilla, or MTE-internal header — only the standard library.
The one deliberate exception is the opt-in PluginApi/Qt/ folder
(PluginQtGlue.h, ISettingsPage.h, ThemeFormat.h) for plugins that ship
their own Qt widgets.
The same contract is fulfilled by two backends: the native Qt loader
(shared libraries) and the out-of-process Python backend
(Python plugins). This section documents the
C++ surface; every service here that is backend-neutral has a Python
counterpart on ctx.
Plugin lifecycle¶
A native plugin implements one class:
class IPlugin {
public:
virtual PluginInfo info() const = 0; // id, name, version, apiVersion
virtual bool initialize(IPluginContext& ctx) = 0; // register everything here
virtual void shutdown() noexcept = 0; // release every token
};
info()must return the sameidandapiVersionasplugin.json; the host cross-checks and refuses to load on mismatch.initialize()runs once at startup (after every plugin is discovered). Register commands, menus, providers, and panels here; returnfalseonly on a real failure (the host unloads the plugin and logs it).shutdown()runs at exit (or when the plugin is being unloaded). It must not throw;reset()every token and null your stored service pointers.
RAII tokens¶
Every registration returns a token (CommandToken, MenuToken, DockToken,
SettingsPageToken, Subscription, …) — a move-only BasicToken that
owns the registration: destroying or reset()-ing it revokes the
command / menu item / panel / subscription. Keep each token as a member and
release it in shutdown(). Two accessors matter:
id()— non-destructiveTokenIdview, for APIs that address the item without taking it (e.g.IMenuRegistry::setItemChecked/setItemEnabled).release()— detaches and returns the id; the token no longer revokes anything. Rarely what you want.
The service locator¶
initialize() receives an IPluginContext, a service locator owned by the host.
Do not store the context past initialize(); store the individual service
references you need (each service outlives every plugin).
| Accessor | Interface | Use for |
|---|---|---|
ctx.editor() |
IEditorService |
read/edit buffer, selection, caret, files, search, bookmarks, indentation, split-view groups |
ctx.commands() |
ICommandRegistry |
register / invoke user actions (with shortcuts) |
ctx.menus() |
IMenuRegistry |
menu items, separators, checkable toggles, greying items out |
ctx.docks() |
IDockRegistry |
dockable side panels |
ctx.documents() |
IDocumentTypeRegistry |
first-class MainWindow tab types (diff, log view, …) |
ctx.compare() |
ICompareService |
open the two-pane compare view |
ctx.diagnostics() |
IDiagnosticsSink |
publish squiggles + margin glyphs |
ctx.events() |
IEventBus |
subscribe to / publish editor events |
ctx.completions() |
ICompletionRegistry |
contribute auto-completion candidates |
ctx.contextMenu() |
IContextMenuRegistry |
dynamic entries in the editor's right-click menu |
ctx.snippetSessions() |
ISnippetSessionService |
provide Tab-expandable snippets (host runs the field session) |
ctx.hover() |
IHoverRegistry |
hover tooltips |
ctx.signatureHelp() |
ISignatureHelpRegistry |
call tips on ( / , |
ctx.symbols() |
ISymbolRegistry |
document / workspace symbols (go-to-symbol) |
ctx.languages() |
ILanguageRegistry |
list languages, read / switch a document's language, contribute a language definition |
ctx.statusBar() |
IStatusBar |
transient messages + persistent segments |
ctx.settings() |
ISettings |
persistent per-plugin key/value config |
ctx.appSettings() |
const ISettings& |
read app-wide settings (AppSettingsKeys.h) |
ctx.documentSettings() |
IDocumentSettings |
.editorconfig-layered per-document config |
ctx.settingsRegistry() |
ISettingsRegistry |
add a page to the Preferences dialog |
ctx.themePalette() |
IThemePalette |
named theme colours, Theme Editor integration |
ctx.lineMarkers() |
ILineMarkerService |
margin glyphs and line tints that follow edits (breakpoints, change strips); the claim on the margin click |
ctx.log() |
ILogger |
trace / debug / info / warning / error |
ctx.pluginDataDir() |
std::filesystem::path |
your private writable directory |
ctx.diagnosticsDir() |
std::filesystem::path |
shared crash-artifacts + log dir (read-only for plugins) |
Extending the API later means adding a new service interface and accessor — a compatible addition under the versioning rule: existing plugins keep loading. The toolchain a plugin build itself has to match (compiler, runtime, Qt minor) is listed under Writing a plugin, "Build requirements".
IEditorService — the document model¶
The editor as seen by plugins — no Scintilla, no Qt. Documents are referenced by
an opaque DocumentId (std::uint64_t, 0 = invalid). Offsets are byte
offsets into UTF-8 text; MatchRange is half-open {start, end} with
noMatch() meaning "not found"; EditorPosition is 0-based {line, column}.
// Documents
DocumentId activeDocument() const;
std::vector<DocumentId> openDocuments() const;
void setActiveDocument(DocumentId);
std::optional<std::filesystem::path> filePath(DocumentId) const;
DocumentId openFile(std::filesystem::path);
DocumentId openFileAt(std::filesystem::path, EditorPosition); // jump there
void revealLine(DocumentId, std::int64_t line); // show, don't move
bool saveDocument(DocumentId);
bool closeDocument(DocumentId); // prompts if modified
bool isModified(DocumentId) const;
DocumentEncoding documentEncoding(DocumentId) const;
void setDocumentEncoding(DocumentId, DocumentEncoding);
LineEnding lineEnding(DocumentId) const;
void setLineEnding(DocumentId, LineEnding);
// Text, selection, caret
std::string text(DocumentId) const; // whole buffer
std::string textRange(DocumentId, start, end) const;
void setText(DocumentId, std::string_view);
std::int64_t length(DocumentId) const;
std::string selectedText(DocumentId) const;
void replaceSelection(DocumentId, std::string_view);
MatchRange selectionRange(DocumentId) const;
void setSelection(DocumentId, MatchRange);
EditorPosition caret(DocumentId) const;
void setCaret(DocumentId, EditorPosition);
// Search & replace (SearchOptions: case, whole-word, regex, wrap)
MatchRange findInRange(DocumentId, needle, range, SearchOptions) const;
void replaceTarget(DocumentId, MatchRange, std::string_view);
// Bookmarks, marks, URL highlighting
void toggleBookmark(DocumentId, line); std::vector<std::int64_t> bookmarkedLines(DocumentId) const;
void markRange(DocumentId, MatchRange); void clearMarks(DocumentId);
// Language & indentation
std::string colorizerId(DocumentId) const; // "cpp", "" if none
IndentationStyle indentation(DocumentId) const; // {useTabs, width}
void setIndentation(DocumentId, bool useTabs, int width);
void setWrapIndent(DocumentId, WrapIndentMode); // where wrapped
WrapIndentMode wrapIndent(DocumentId) const; // continuation rows
// start: LeftMargin / SameAsLine / Indented
// Semantic classification (from the active colorizer's style rules;
// cheap -- no re-lex). CategorySpan = {start, length, SyntaxCategory}.
// The spell checker keeps to Comment/Documentation/String spans this way.
std::vector<CategorySpan> textCategories(DocumentId, start, end) const;
// Clipboard
std::string clipboardText() const;
It also exposes the split-view tab-group layout in backend-neutral terms
(groupCount(), groupOf(), groupWeights(), moveDocumentToGroup(),
setGroupWeights()), so a plugin can capture and restore pane arrangements
without seeing Qt's splitter. See PluginApi/IEditorService.h for the full
list — the header is the authoritative reference.
A document shown in two panes is one document with one DocumentId, so
groupOf() can only name one of its seats. Two methods describe and rebuild
a split:
std::vector<int> groupsOf(DocumentId doc) const; // every pane showing it
bool showDocumentInGroup(DocumentId doc, int group); // put it in one more
showDocumentInGroup() activates a view already in that pane, and otherwise
creates a second shared view there — the same thing View ▸ Split
Document Right does, so no DocumentOpened is raised and both views edit
one document. Panes are the plugin-facing concept; individual views are
not, which is why the call names a pane rather than handing back a view.
Taking the user somewhere¶
openFileAt(path, position) is the one call behind "go to build error", "jump
to stack frame" or "open search hit", so they all land the same way. It opens
the file exactly as openFile() does (an open file's tab is activated, not
opened twice), makes it the active document, places the caret with no
selection, unfolds a fold that hides the line, scrolls the line into view —
centred when it was off-screen, not scrolled when it was already visible —
and gives the editor keyboard focus. The position is clamped, never
refused: a line past the end lands on the last line, a column past the end
of the line lands at its end, negative values count as 0. It returns the
document the caret was placed in, or 0 when the file could not be opened —
and then nothing moved.
revealLine(doc, line) shows a place without taking the user there: it
unfolds and scrolls the pane that shows doc, but does not move the caret or
the selection, does not switch tabs and does not take focus — a panel
previewing a stack frame or following its selection uses it.
Document encoding¶
A document's on-disk character encoding is core state, not a plugin's. The
host detects it when the file is read and writes the same encoding back on
every save, so a UTF-8-BOM file keeps its mark and a UTF-16 file stays UTF-16.
DocumentEncoding (PluginApi/DocumentEncoding.h) is a backend-agnostic POD
enum covering the five Unicode/ANSI primaries plus the legacy code pages.
setDocumentEncoding() records a choice: it changes no bytes and re-decodes
nothing — the buffer is already Unicode, and the new encoding takes effect on
the next save. A plugin offering a "convert to" action should therefore also
call setModified(doc, true); one offering "interpret as" reloads the text
itself first and then records the choice. The host publishes
DocumentEncodingChanged whenever the value actually changes. A project
.editorconfig charset still wins at save time.
Document line endings¶
A document's line terminator is core state too. The host detects it when
the file is read, pushes it into the view so Enter inserts that sequence, and
writes the buffer's bytes back verbatim — so a Unix file edited on Windows
stays LF and a save with no edits is byte-identical. LineEnding
(PluginApi/LineEnding.h) is a backend-agnostic POD enum — Unknown / Lf /
CrLf / Cr — naming the byte sequence rather than a backend constant. It
ships two inline helpers: lineEndingBytes() and platformLineEnding() (what
a new buffer starts with: CRLF on Windows, LF elsewhere).
setLineEnding() records the choice, retunes the document's view so the next
Enter inserts it, and decides what the next save writes. It converts no
existing text: a plugin rewriting the whole buffer (the EOL Conversion
commands) replaces the text itself first, so the conversion stays one undoable
edit. The host publishes DocumentLineEndingChanged whenever the value
actually changes. A project .editorconfig end_of_line still wins at save
time, and moves the document onto that convention with it.
Threading
All context and service calls happen on the host's UI thread. Do not call services from a background thread you spawned; marshal back first.
Versioning¶
The contract carries a two-part version, both in PluginApi/PluginInfo.h:
constexpr std::uint32_t MTE_PLUGIN_API_VERSION = 1; // major
constexpr std::uint32_t MTE_PLUGIN_API_MINOR_VERSION = 0; // minor
What decides between them is who implements the interface:
| Change | Kind | Version |
|---|---|---|
A method appended at the end of an interface only the editor implements and plugins only call (IEditorService, IPluginContext, every I…Registry, ILineMarkerService, …) |
compatible | minor + 1 |
| A new interface, a new event struct, a new enumerator at the end of an enum | compatible | minor + 1 |
Any change to a struct that crosses the boundary (PluginInfo, ContextMenuRequest, DockPanelDescriptor, an event struct) |
breaking | major + 1 |
| A changed signature, a removed or reordered method, a new overload of an existing name | breaking | major + 1 |
Any change to an interface a plugin implements (IPlugin, IHoverProvider, IContextMenuProvider, IDockPanel, ISettingsPage, …) |
breaking | major + 1 |
| A release built with a toolchain whose binary interface differs from the previous one — another compiler family or standard library, a new Qt major | breaking | major + 1 |
A newer toolset of the same compiler family, a different C++ standard level and a newer Qt minor are not such a break and move neither number; see Build requirements for what a native plugin has to match.
The editor loads a plugin only when
apiVersioninplugin.jsonandIPlugin::info().apiVersionequal its own major, andapiMinorVersioninplugin.json(optional, default0) andIPlugin::info().apiMinorVersionare not above its own minor. The value ininfo()defaults to the minor of the headers the plugin was compiled against; a plugin that knows it uses nothing newer may state a lower number inplugin.json, and must still pass the second check.
A refused plugin stays listed on Preferences ▸ Plugins with the reason
("Incompatible (API mismatch)", "Needs a newer version of OctoEdit") and gets
a log entry — never a crash. A Python plugin has no info() compiled against
the headers, so its plugin.json is its only statement: declare the minor of
the newest call you use.
Pre-release policy
Before the first public release the contract may still change without bumping
either number; both stay at 1 / 0 until then. The table above is what
every change is judged by from the first release on.