Aller au contenu

Commandes et menus

Commandes

ICommandRegistry est l'entonnoir d'exécution unique de toute action invocable par l'utilisateur — éléments de menu, boutons de barre d'outils, raccourcis et (plus tard) scripts Python.

namespace platform_id {              // keys for platformShortcuts
    inline constexpr std::string_view kWindows = "windows";
    inline constexpr std::string_view kMacOS   = "macos";
    inline constexpr std::string_view kLinux   = "linux";
}

namespace command_scope {            // values for CommandDescriptor::scope
    inline constexpr std::string_view kApplication  = "application";
    inline constexpr std::string_view kPluginWindow = "pluginWindow";
}

struct CommandDescriptor {
    std::string id;          // "helloworld.sayHi"
    std::string title;       // "Say Hi"
    std::string shortcut;    // portable default: "Ctrl+Alt+H"
    std::string category;    // "Hello"
    std::string scope;       // "" / "application" | "pluginWindow"
    std::map<std::string, std::string> platformShortcuts; // optional per-platform
    bool requiresEditor         = false;  // needs an active document
    bool requiresWritableEditor = false;  // ...and changes its text
};

// Convenience wrappers, so an existing aggregate literal only has to be
// wrapped rather than rewritten:
CommandDescriptor needsEditor(CommandDescriptor);          // requiresEditor
CommandDescriptor needsWritableEditor(CommandDescriptor);  // + writable

using CommandHandler = std::function<void()>;

class ICommandRegistry {
public:
    virtual CommandToken registerCommand(CommandDescriptor, CommandHandler) = 0;
    virtual void         invoke(std::string_view commandId) = 0;
    virtual bool         exists(std::string_view commandId) const = 0;
    // The user-resolved sequence in effect right now ("" = unbound),
    // NOT the declared default. See "User remapping" below.
    virtual std::string  effectiveShortcut(std::string_view commandId) const = 0;
};

registerCommand renvoie un CommandToken (RAII). Sa destruction supprime la commande, ses entrées de menu et son raccourci. Conservez chaque jeton comme membre afin que tout le plugin se démonte automatiquement dans shutdown().

Exigences vis-à-vis de l'éditeur

requiresEditor / requiresWritableEditor indiquent à l'hôte ce dont la commande a besoin du document actif, afin qu'il puisse la griser au lieu de laisser l'utilisateur invoquer quelque chose qui ne fait silencieusement rien.

Indicateur À définir quand la commande… Elle est grisée sur…
requiresEditor lit le tampon, la sélection ou le curseur, ou pose des marqueurs (Signets, Mark, Rechercher, Aller à la définition) un onglet sans aucun document — un onglet de comparaison ou de vue de journal, où activeDocument() renvoie 0
requiresWritableEditor modifie le texte (Commentaire, Casse, Éditeur de colonnes, Augmenter l'indentation, Lignes…, Formater le document, Renommer le symbole) ce qui précède et un document en lecture seule : un fichier suivi par tail -f, une session --viewer ou --readonly

L'indicateur « writable » implique l'autre : un descripteur peut donc ne définir que le plus fort. Les deux valent false par défaut — « toujours disponible », la bonne réponse pour les commandes qui ouvrent une boîte de dialogue, un dossier ou un panneau.

Le grisage touche l'entrée de menu, la palette de commandes et le raccourci. Il ne peut que retirer de la disponibilité : une entrée que votre extension a elle-même désactivée via IMenuRegistry::setItemEnabled reste désactivée quel que soit le document actif.

Enveloppez le descripteur à l'appel :

using MTE::plugin::needsWritableEditor;

m_toggleComment = ctx.commands().registerCommand(
    needsWritableEditor({ "comment.toggle", "Toggle Comment",
                          "Ctrl+Q", "Comment" }),
    [this] { onToggleComment(); });

En Python, passez les arguments nommés :

@ctx.commands.command(id="wc.upper", title="Upper Case",
                      requires_writable_editor=True)
def _upper():
    ...

Raccourcis par plateforme

shortcut est une combinaison portable unique. Qt fait déjà correspondre le modificateur "Ctrl" au modificateur natif de la plateforme — ⌘ Command sur macOS — donc "Ctrl+F" y devient ⌘F sans effort supplémentaire. Vous n'avez besoin de plus que lorsque ce mappage automatique tomberait sur une combinaison réservée par l'OS (p. ex. "Ctrl+Q" → ⌘Q = Quitter sur macOS).

Dans ces cas-là, renseignez platformShortcuts, indexé par platform_id. L'hôte renvoie l'entrée correspondant à la plateforme en cours, ou se rabat sur shortcut quand une plateforme est absente (ou associée à une chaîne vide). Pas de #ifdef, pas de types Qt — uniquement des données — de sorte que la liaison reste indépendante du backend, et une nouvelle plateforme n'est qu'une nouvelle clé :

MTE::plugin::CommandDescriptor desc;
desc.id       = "comment.toggle";
desc.title    = "Toggle Single Line Comment";
desc.shortcut = "Ctrl+Q";                       // Windows / Linux → Ctrl+Q
desc.platformShortcuts = {                       // macOS → ⌃Q (the portable
    { std::string(MTE::plugin::platform_id::kMacOS), "Meta+Q" } }; // "Meta" = ⌃
desc.category = "Comment";

Combinaisons ⌘ réservées sur macOS à vérifier face à un simple Ctrl+<key> : ⌘Q (Quitter), ⌘W (Fermer), ⌘H (Masquer), ⌘M (Réduire), ⌘, (Préférences), ⌘Espace (Spotlight). Pour un raccourci Ctrl+Alt+<key> (⌥⌘ sur macOS), évitez aussi ⌥⌘H (Masquer les autres), ⌥⌘M (Tout réduire), ⌥⌘D (Dock) et ⌥⌘Esc (Forcer à quitter).

Deux règles supplémentaires protègent la saisie de texte :

  • Jamais un simple Alt+<letter> comme raccourci portable. Sous Windows, c'est l'espace des mnémoniques de menu, et sur macOS ⌥+lettre compose des caractères accentués (⌥U est la touche morte du tréma, ⌥C saisit ç) ; le raccourci les avalerait — donnez à une telle commande un remplacement {"macos", "Meta+Alt+<key>"} (⌃⌥).
  • Ctrl+Alt+<key> est AltGr+<key> sous Windows. Les dispositions nationales y saisissent des caractères (français : € @ ; polonais : ś ł ą ć ę ń ó ź ż €). L'hôte les garde saisissables — un Alt droit produisant un caractère imprimable est transmis comme texte, un Ctrl+Alt gauche exécute la commande — de tels raccourcis sont donc permis, mais préférez des lettres qui ne saisissent rien sur les dispositions courantes (B D F G H I J K P R T V W Y), et jamais Ctrl+Alt+<digit>.

Remappage par l'utilisateur

Les shortcut / platformShortcuts déclarés sont des valeurs par défaut. L'utilisateur peut réaffecter ou délier chaque commande dans le mappeur de raccourcis du cœur (Préférences ▸ Raccourcis), et les conflits sont résolus côté hôte : des doublons de même portée ne coexistent jamais — le perdant est suspendu pour la session et signalé. Rien de tout cela ne demande de code de plugin pour les commandes ordinaires liées à un menu : l'hôte possède leur QAction et la relie à chaud.

scope — commandes dans des fenêtres possédées par le plugin

Les commandes dont la combinaison vit dans un widget que le plugin possède (la barre d'outils d'une fenêtre outil plutôt que le menu principal) déclarent scope = command_scope::kPluginWindow. L'hôte ne lie alors jamais la combinaison lui-même ; il se contente de lister la commande dans le mappeur et de résoudre le choix de l'utilisateur. Le plugin applique la liaison à ses propres widgets :

  • interrogez ctx.commands().effectiveShortcut(id) lors de la construction des widgets, et
  • abonnez-vous à events::CommandShortcutChanged { commandId, sequence } et réappliquez quand l'un de vos ids change ("" = désormais non lié).

Le domaine de conflit est global en v1 — une combinaison pluginWindow refuse d'entrer en collision avec une combinaison application. Exemple fonctionnel : la barre d'outils Log View du Log Analyzer (loganalyzer.logView.nextError F8 / .prevError Shift+F8 / .goToTime Ctrl+G).

Les chemins de menu utilisent / comme séparateur ; l'hôte crée les sous-menus intermédiaires à la demande :

class IMenuRegistry {
public:
    virtual MenuToken addItem(std::string menuPath, std::string commandId) = 0;
    virtual MenuToken addSeparator(std::string menuPath) = 0;
    virtual void      setItemChecked(TokenId itemToken, bool checked) = 0;
    virtual void      setItemEnabled(TokenId itemToken, bool enabled) = 0;
};
m_menu = ctx.menus().addItem("File/Export/As HTML...", "myplugin.exportHtml");

MenuToken est RAII, comme CommandToken.

Éléments de menu cochables

Un élément de menu représentant un état actif/inactif persistant peut porter une coche visible. setItemChecked prend le TokenId de l'élément — utilisez l'accesseur non destructif id() du jeton (release() abandonnerait la propriété) :

m_toggle = ctx.menus().addItem("View/Auto-open preview", "myplugin.toggle");

// Seed the mark from the persisted setting so the menu matches reality
// before the user first opens it:
ctx.menus().setItemChecked(m_toggle.id(),
                           ctx.settings().getBool("autoOpen", true));

// Inside the command handler, after flipping the setting:
ctx.menus().setItemChecked(m_toggle.id(), nowEnabled);

Le premier appel promeut l'élément en élément cochable ; les appels suivants ne font que basculer l'état. Un jeton inconnu est un no-op silencieux, donc un callback retardé qui se déclenche après l'arrêt est sans danger.

Griser les éléments de menu

Une commande soumise à une condition préalable — un dossier à fermer, un onglet fermé à rouvrir — devrait griser son élément de menu tant que cette condition n'est pas remplie, plutôt que d'offrir une entrée qui ne fait silencieusement rien. setItemEnabled prend le TokenId de l'élément, exactement comme setItemChecked :

m_closeItem = ctx.menus().addItem("File/Close Folder", "myplugin.closeFolder");

// Nothing to close yet:
ctx.menus().setItemEnabled(m_closeItem.id(), false);

// ... and from every place that changes the state the command needs:
ctx.menus().setItemEnabled(m_closeItem.id(), !folder.empty());

Un élément désactivé reste visible mais ne peut pas être déclenché, ni par un clic ni par son raccourci clavier. L'appel est idempotent — répéter la même valeur est attendu, vous n'avez donc jamais à mémoriser l'état précédent — et un jeton inconnu est un no-op silencieux, comme ci-dessus. Les éléments démarrent activés : un élément indisponible au démarrage demande donc un appel juste après addItem.

Placement des menus (plugin.json)

L'emplacement du menu de premier niveau d'un plugin est piloté par les données via le tableau menus de plugin.json — l'hôte ne code jamais en dur les noms de menus des plugins :

"menus": [
  { "title": "Plugins", "barPriority": 570, "itemPriority": 100 }
]
  • title — le menu de premier niveau ; correspond au premier segment (séparé par /) de votre menuPath. Donnez-lui un mnémonique clavier avec & ("Lo&g Analyzer") et employez la même orthographe dans chaque menuPath : sous Windows, Alt+lettre ouvre le menu, et chaque titre de la barre a besoin d'une lettre qui lui est propre, dans chaque langue — choisissez-en une qui soit libre parmi Fichier, Édition, Recherche, Encodage, Comparer, Affichage, Langage, Extensions, Fenêtre, À propos et Aide, et laissez chaque traduction choisir la sienne. La correspondance des menus ignore le marqueur.
  • barPriority — position horizontale dans la barre de menus (plus bas = plus à gauche). Les menus du cœur sont fixes, espacés de 100 : Fichier 100, Édition 200, Affichage 300, Langage 400, Comparer 500, Fenêtre 600 (À propos 1000, toujours tout à droite) ; les plugins comblent les intervalles (Recherche 250, Encodage 350, Analyseur de journaux 540, Extensions 570).
  • itemPriority — rang vertical du bloc de ce plugin au sein de title (plus bas = plus haut ; 1000 par défaut). Les éléments à l'intérieur du bloc d'un même plugin gardent l'ordre dans lequel ils sont ajoutés dans le code.

Note

Comme plugin.json est embarqué en tant que métadonnées de plugin Qt, ses modifications ne prennent effet qu'après une recompilation.