API de plugin — vue d'ensemble¶
Tous les types de PluginApi vivent dans namespace MTE::plugin. Les en-têtes
publics ne peuvent inclure aucun en-tête Qt, Scintilla ou interne à MTE —
uniquement la bibliothèque standard. La seule exception délibérée est le
dossier optionnel PluginApi/Qt/ (PluginQtGlue.h, ISettingsPage.h,
ThemeFormat.h) pour les plugins qui livrent leurs propres widgets Qt.
Le même contrat est rempli par deux backends : le chargeur natif Qt
(bibliothèques partagées) et le backend Python hors processus
(plugins Python). Cette section documente la
surface C++ ; chaque service décrit ici qui est neutre vis-à-vis du backend a
un équivalent Python sur ctx.
Cycle de vie du plugin¶
Un plugin natif implémente une seule classe :
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()doit renvoyer les mêmesidetapiVersionqueplugin.json; l'hôte effectue une vérification croisée et refuse le chargement en cas d'incohérence.initialize()s'exécute une fois au démarrage (après la découverte de tous les plugins). Enregistrez-y vos commandes, menus, fournisseurs et panneaux ; ne renvoyezfalsequ'en cas d'échec réel (l'hôte décharge alors le plugin et journalise l'incident).shutdown()s'exécute à la sortie (ou quand le plugin est déchargé). Elle ne doit pas lever d'exception ; faitesreset()sur chaque jeton et mettez à null vos pointeurs de services stockés.
Jetons RAII¶
Chaque enregistrement renvoie un jeton (CommandToken, MenuToken,
DockToken, SettingsPageToken, Subscription, …) — un BasicToken
déplaçable uniquement (move-only) qui possède l'enregistrement : le
détruire ou appeler reset() révoque la commande / l'élément de menu / le
panneau / l'abonnement. Conservez chaque jeton comme membre et libérez-le dans
shutdown(). Deux accesseurs comptent :
id()— vueTokenIdnon destructive, pour les API qui désignent l'élément sans en prendre possession (p. ex.IMenuRegistry::setItemChecked/setItemEnabled).release()— détache et renvoie l'id ; le jeton ne révoque alors plus rien. Rarement ce que vous voulez.
Le localisateur de services¶
initialize() reçoit un IPluginContext, un localisateur de services possédé
par l'hôte. Ne stockez pas le contexte au-delà d'initialize() ; stockez
les références des services individuels dont vous avez besoin (chaque service
survit à tous les plugins).
| Accesseur | Interface | À utiliser pour |
|---|---|---|
ctx.editor() |
IEditorService |
lire/modifier le tampon, la sélection, le caret, les fichiers, la recherche, les signets, l'indentation, les groupes de vue scindée |
ctx.commands() |
ICommandRegistry |
enregistrer / invoquer des actions utilisateur (avec raccourcis) |
ctx.menus() |
IMenuRegistry |
éléments de menu, séparateurs, bascules cochables, éléments grisés |
ctx.docks() |
IDockRegistry |
panneaux latéraux ancrables |
ctx.documents() |
IDocumentTypeRegistry |
types d'onglets de première classe de la MainWindow (diff, vue de log, …) |
ctx.compare() |
ICompareService |
ouvrir la vue de comparaison à deux volets |
ctx.diagnostics() |
IDiagnosticsSink |
publier des soulignements ondulés + glyphes de marge |
ctx.events() |
IEventBus |
s'abonner aux événements de l'éditeur / en publier |
ctx.completions() |
ICompletionRegistry |
contribuer des candidats d'auto-complétion |
ctx.contextMenu() |
IContextMenuRegistry |
entrées dynamiques du menu contextuel de l'éditeur |
ctx.snippetSessions() |
ISnippetSessionService |
fournir des snippets extensibles par Tab (l'hôte gère la session de champs) |
ctx.hover() |
IHoverRegistry |
infobulles de survol |
ctx.signatureHelp() |
ISignatureHelpRegistry |
info-bulles d'appel sur ( / , |
ctx.symbols() |
ISymbolRegistry |
symboles du document / de l'espace de travail (aller au symbole) |
ctx.languages() |
ILanguageRegistry |
lister les langages, lire / changer le langage d'un document, contribuer une définition de langage |
ctx.statusBar() |
IStatusBar |
messages transitoires + segments persistants |
ctx.settings() |
ISettings |
configuration clé/valeur persistante par plugin |
ctx.appSettings() |
const ISettings& |
lire les réglages globaux de l'application (AppSettingsKeys.h) |
ctx.documentSettings() |
IDocumentSettings |
configuration par document superposée par .editorconfig |
ctx.settingsRegistry() |
ISettingsRegistry |
ajouter une page à la boîte de dialogue Préférences |
ctx.themePalette() |
IThemePalette |
couleurs de thème nommées, intégration avec l'éditeur de thèmes |
ctx.lineMarkers() |
ILineMarkerService |
glyphes de marge et teintes de ligne qui suivent les modifications (points d'arrêt, bandes de modifications) ; la revendication du clic dans la marge |
ctx.log() |
ILogger |
trace / debug / info / warning / error |
ctx.pluginDataDir() |
std::filesystem::path |
votre répertoire privé accessible en écriture |
ctx.diagnosticsDir() |
std::filesystem::path |
répertoire partagé des artefacts de crash + journaux (lecture seule pour les plugins) |
Étendre l'API plus tard consiste à ajouter une nouvelle interface de service et son accesseur — un ajout compatible selon la règle de versionnement : les plugins existants continuent de se charger. La chaîne d'outils que le build du plugin lui-même doit respecter (compilateur, runtime, version mineure de Qt) est décrite dans Écrire un plugin, section « Prérequis de compilation ».
IEditorService — le modèle de document¶
L'éditeur tel que vu par les plugins — sans Scintilla, sans Qt. Les documents
sont référencés par un DocumentId opaque (std::uint64_t, 0 = invalide).
Les offsets sont des offsets en octets dans le texte UTF-8 ; MatchRange
est un intervalle semi-ouvert {start, end} où noMatch() signifie
« non trouvé » ; EditorPosition est un {line, column} en base 0.
// 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;
Il expose aussi la disposition des groupes d'onglets de la vue scindée en
termes neutres vis-à-vis du backend (groupCount(), groupOf(),
groupWeights(), moveDocumentToGroup(), setGroupWeights()), afin qu'un
plugin puisse capturer et restaurer l'agencement des volets sans voir le
splitter de Qt. Consultez PluginApi/IEditorService.h pour la liste
complète — l'en-tête est la référence qui fait foi.
Un document affiché dans deux volets est un seul document, avec un seul
DocumentId : groupOf() ne peut donc nommer qu'une de ses places. Deux
méthodes décrivent et reconstruisent une scission :
std::vector<int> groupsOf(DocumentId doc) const; // every pane showing it
bool showDocumentInGroup(DocumentId doc, int group); // put it in one more
showDocumentInGroup() active une vue déjà présente dans ce volet et, sinon,
y crée une seconde vue partagée — exactement ce que fait Affichage ▸
Scinder le document à droite — si bien qu'aucun DocumentOpened n'est émis
et que les deux vues éditent un seul document. Les volets sont le concept
exposé aux extensions, les vues individuelles ne le sont pas : l'appel
désigne donc un volet au lieu de rendre une poignée de vue.
Emmener l'utilisateur quelque part¶
openFileAt(path, position) est l'unique appel derrière « aller à l'erreur de
compilation », « sauter à la trame de pile » ou « ouvrir le résultat de
recherche », si bien que tous arrivent de la même façon. Il ouvre le fichier
exactement comme openFile() (l'onglet d'un fichier déjà ouvert est activé,
pas ouvert une seconde fois), en fait le document actif, place le curseur sans
sélection, déplie un repli qui masque la ligne, fait défiler la ligne dans la
vue — centrée si elle était hors de l'écran, sans défilement si elle était
déjà visible — et donne le focus clavier à l'éditeur. La position est
bornée, jamais refusée : une ligne au-delà de la fin arrive sur la
dernière ligne, une colonne au-delà de la fin de la ligne arrive à sa fin, les
valeurs négatives comptent pour 0. Il renvoie le document dans lequel le
curseur a été placé, ou 0 si le fichier n'a pas pu être ouvert — et alors
rien n'a bougé.
revealLine(doc, line) montre un endroit sans y emmener l'utilisateur : il
déplie et fait défiler le volet qui affiche doc, mais ne déplace ni le
curseur ni la sélection, ne change pas d'onglet et ne prend pas le focus — un
panneau qui prévisualise une trame de pile ou suit sa sélection l'utilise.
Encodage du document¶
L'encodage de caractères d'un document sur le disque est un état du cœur,
pas celui d'une extension. L'hôte le détecte à la lecture du fichier et réécrit
le même encodage à chaque enregistrement, si bien qu'un fichier UTF-8-BOM
conserve sa marque et qu'un fichier UTF-16 reste en UTF-16. DocumentEncoding
(PluginApi/DocumentEncoding.h) est un énuméré POD neutre vis-à-vis du backend
couvrant les cinq encodages Unicode/ANSI principaux ainsi que les pages de
codes héritées.
setDocumentEncoding() enregistre un choix : il ne change aucun octet et ne
redécode rien — le tampon est déjà en Unicode, et le nouvel encodage prend
effet au prochain enregistrement. Une extension qui propose une action
« convertir en » doit donc aussi appeler setModified(doc, true) ; une
extension qui propose « interpréter comme » recharge d'abord le texte
elle-même, puis enregistre le choix. L'hôte publie DocumentEncodingChanged
dès que la valeur change réellement. Un charset dans le .editorconfig du
projet reste prioritaire au moment de l'enregistrement.
Fins de ligne d'un document¶
La fin de ligne d'un document est elle aussi un état du cœur. L'hôte la
détecte à la lecture du fichier, la transmet à la vue pour que Enter insère
cette séquence, et réécrit les octets du tampon tels quels — un fichier Unix
édité sous Windows reste donc en LF, et un enregistrement sans modification est
identique octet pour octet. LineEnding (PluginApi/LineEnding.h) est une
énumération POD indépendante du backend — Unknown / Lf / CrLf / Cr —
qui nomme la séquence d'octets plutôt qu'une constante de backend. Elle
fournit deux aides inline : lineEndingBytes() et platformLineEnding() (ce
avec quoi démarre un nouveau tampon : CRLF sous Windows, LF ailleurs).
setLineEnding() enregistre le choix, réaccorde la vue du document pour que le
prochain Enter l'insère, et décide de ce qu'écrira l'enregistrement suivant.
Elle ne convertit aucun texte existant : une extension qui réécrit tout le
tampon (les commandes de Conversion des fins de ligne) remplace le texte
elle-même d'abord, afin que la conversion reste une seule édition annulable.
L'hôte publie DocumentLineEndingChanged dès que la valeur change réellement.
Un end_of_line du .editorconfig du projet l'emporte toujours à
l'enregistrement, et bascule le document sur cette convention avec lui.
Threads
Tous les appels au contexte et aux services ont lieu sur le thread UI de l'hôte. N'appelez pas les services depuis un thread d'arrière-plan que vous avez créé ; revenez d'abord sur le thread UI.
Gestion des versions¶
Le contrat porte une version en deux parties, toutes deux dans
PluginApi/PluginInfo.h :
constexpr std::uint32_t MTE_PLUGIN_API_VERSION = 1; // major
constexpr std::uint32_t MTE_PLUGIN_API_MINOR_VERSION = 0; // minor
Ce qui décide entre les deux, c'est qui implémente l'interface :
| Changement | Nature | Version |
|---|---|---|
Une méthode ajoutée à la fin d'une interface que seul l'éditeur implémente et que les plugins se contentent d'appeler (IEditorService, IPluginContext, chaque I…Registry, ILineMarkerService, …) |
compatible | mineure + 1 |
| Une nouvelle interface, une nouvelle structure d'événement, un nouvel énumérateur à la fin d'une énumération | compatible | mineure + 1 |
Toute modification d'une structure qui franchit la frontière (PluginInfo, ContextMenuRequest, DockPanelDescriptor, une structure d'événement) |
incompatible | majeure + 1 |
| Une signature modifiée, une méthode supprimée ou réordonnée, une nouvelle surcharge d'un nom existant | incompatible | majeure + 1 |
Toute modification d'une interface qu'un plugin implémente (IPlugin, IHoverProvider, IContextMenuProvider, IDockPanel, ISettingsPage, …) |
incompatible | majeure + 1 |
| Une version construite avec une chaîne d'outils dont l'interface binaire diffère de la précédente — une autre famille de compilateurs ou une autre bibliothèque standard, une nouvelle version majeure de Qt | incompatible | majeure + 1 |
Un jeu d'outils plus récent de la même famille de compilateurs, un autre niveau de norme C++ et une version mineure plus récente de Qt ne constituent pas une telle rupture et ne modifient aucun des deux nombres ; voir Prérequis de compilation pour ce qu'un plugin natif doit respecter.
L'éditeur ne charge un plugin que si
apiVersiondansplugin.jsonetIPlugin::info().apiVersionsont égaux à sa propre version majeure, etapiMinorVersiondansplugin.json(facultatif,0par défaut) etIPlugin::info().apiMinorVersionne dépassent pas sa propre version mineure. La valeur deinfo()vaut par défaut la mineure des en-têtes avec lesquels le plugin a été compilé ; un plugin qui sait n'utiliser rien de plus récent peut indiquer un nombre plus bas dansplugin.json, et doit tout de même passer la seconde vérification.
Un plugin refusé reste listé dans Préférences ▸ Extensions avec la raison
(« Incompatible (incompatibilité d'API) », « Nécessite une version plus récente
d'OctoEdit ») et reçoit une entrée de journal — jamais un plantage. Un plugin
Python n'a pas d'info() compilé avec les en-têtes, son plugin.json est donc
sa seule déclaration : indiquez la mineure de l'appel le plus récent que vous
utilisez.
Politique de pré-version
Avant la première version publique, le contrat peut encore changer sans
incrémenter l'un ou l'autre nombre ; d'ici là, ils restent à 1 / 0. À
partir de la première version, chaque changement est jugé selon le
tableau ci-dessus.