Skip to content

Commands & menus

Commands

ICommandRegistry is the single execution funnel for every user-invocable action — menu items, toolbar buttons, shortcuts, and (later) Python scripts.

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 returns a CommandToken (RAII). Destroying it removes the command, its menu entries, and its shortcut. Hold every token as a member so the whole plugin tears down automatically in shutdown().

Editor requirements

requiresEditor / requiresWritableEditor tell the host what the command needs from the active document, so it can grey the command out instead of letting the user invoke something that silently does nothing.

Flag Set it when the command… It greys out on…
requiresEditor reads the buffer, the selection or the caret, or places markers (Bookmarks, Mark, Find, Go to Definition) a tab that hosts no document at all — a Diff-Compare or Log-View tab, where activeDocument() returns 0
requiresWritableEditor changes the text (Comment, Convert Case, Column Editor, Increase Indent, Lines…, Format Document, Rename Symbol) the above, and a read-only document: a file followed with tail -f, a --viewer or --readonly session

The writable flag implies the other, so a descriptor may set only the stronger one. Both default to false — "always available", the right answer for commands that open a dialog, a folder or a panel.

The greying reaches the menu item, the command palette and the shortcut. It can only ever take availability away: an item your plugin disabled itself through IMenuRegistry::setItemEnabled stays disabled whatever the active document is.

Wrap the descriptor at the call site:

using MTE::plugin::needsWritableEditor;

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

In Python, pass the keyword arguments:

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

Per-platform shortcuts

shortcut is a single portable chord. Qt already maps the "Ctrl" modifier to the platform's native one — ⌘ Command on macOS — so "Ctrl+F" is ⌘F there with no extra work. You only need more when that automatic mapping would land on a chord the OS reserves (e.g. "Ctrl+Q" → ⌘Q = Quit on macOS).

For those cases set platformShortcuts, keyed by platform_id. The host returns the entry matching the running platform, or falls back to shortcut when a platform is absent (or mapped to an empty string). No #ifdef, no Qt types — just data — so the binding stays backend-agnostic, and a new platform is one new key:

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

Reserved macOS ⌘ chords to check a single Ctrl+<key> against: ⌘Q (Quit), ⌘W (Close), ⌘H (Hide), ⌘M (Minimize), ⌘, (Preferences), ⌘Space (Spotlight). For a Ctrl+Alt+<key> chord (⌥⌘ on macOS) also avoid ⌥⌘H (Hide Others), ⌥⌘M (Minimize All), ⌥⌘D (Dock) and ⌥⌘Esc (Force Quit).

Two more rules keep shortcuts out of the way of typing:

  • Never a bare Alt+<letter> as the portable chord. On Windows that is the menu-mnemonic space, and on macOS ⌥+letter composes accented characters (⌥U is the umlaut dead key, ⌥C types ç), so the shortcut would swallow them — give such a command a {"macos", "Meta+Alt+<key>"} override (⌃⌥).
  • Ctrl+Alt+<key> is AltGr+<key> on Windows. National layouts type letters there (Polish: ś ł ą ć ę ń ó ź ż €; German: @ € µ). The host keeps them typeable — a right-Alt press that produces a printable character is delivered as text, a left Ctrl+Alt press runs the command — so such chords are allowed, but prefer letters that type nothing on the common layouts (B D F G H I J K P R T V W Y) and never Ctrl+Alt+<digit>.

User remapping

The declared shortcut / platformShortcuts are defaults. The user can rebind or unbind every command in the core Shortcut Mapper (Preferences ▸ Shortcuts), and conflicts are resolved host-side: same-scope duplicates never coexist — the loser is suspended for the session and reported. None of this needs plugin code for ordinary menu-bound commands: the host owns their QAction and rebinds it live.

scope — commands in plugin-owned windows

Commands whose chord lives in a widget the plugin owns (a tool window's toolbar rather than the main menu) declare scope = command_scope::kPluginWindow. The host then never binds the chord itself; it only lists the command in the mapper and resolves the user's choice. The plugin applies the binding to its own widgets:

  • query ctx.commands().effectiveShortcut(id) when building the widgets, and
  • subscribe to events::CommandShortcutChanged { commandId, sequence } and re-apply when one of its ids changes ("" = now unbound).

The conflict domain is global in v1 — a pluginWindow chord refuses to collide with an application chord. Working example: the Log Analyzer's Log View toolbar (loganalyzer.logView.nextError F8 / .prevError Shift+F8 / .goToTime Ctrl+G).

Menu paths use / as the separator; the host creates intermediate submenus on demand:

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 is RAII like CommandToken.

Checkable menu items

A menu item that represents a persistent on/off state can carry a visible check mark. setItemChecked takes the item's TokenId — use the token's non-destructive id() accessor (release() would give up ownership):

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

The first call promotes the item to checkable; subsequent calls just flip the state. An unknown token is a silent no-op, so a delayed callback that fires after shutdown is harmless.

Greying out menu items

A command that needs a precondition — a folder to close, a closed tab to restore — should grey its menu item out while that precondition is missing instead of offering an entry that quietly does nothing. setItemEnabled takes the item's TokenId, exactly like 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());

A disabled item stays visible but cannot be triggered, by click or by its keyboard shortcut. The call is idempotent — repeating the same value is expected, so you never have to remember the previous state — and an unknown token is a silent no-op, as above. Items start out enabled, so an item that is unavailable at start-up needs one call right after addItem.

Where a plugin's top-level menu sits is data-driven via the menus array in plugin.json — the host never hard-codes plugin menu names:

"menus": [
  { "title": "Plugins", "barPriority": 570, "itemPriority": 100 }
]
  • title — the top-level menu; matches the first /-separated segment of your menuPath. Give it a keyboard mnemonic with & ("Lo&g Analyzer") and use the same spelling in every menuPath: on Windows Alt+letter opens the menu, and every bar title needs a letter of its own, in every language — pick one that is free among File, Edit, Search, Encoding, Compare, View, Language, Plugins, Window, About and Help, and let each translation choose its own letter. Menu matching ignores the marker.
  • barPriority — horizontal position on the menu bar (lower = further left). Core menus are fixed, spaced 100 apart: File 100, Edit 200, View 300, Language 400, Compare 500, Window 600 (About 1000, always far right); plugins fill the gaps (Search 250, Encoding 350, Log Analyzer 540, Plugins 570).
  • itemPriority — vertical rank of this plugin's block within title (lower = higher up; default 1000). Items inside one plugin's block keep the order they are added in code.

Note

Because plugin.json is embedded as Qt plugin metadata, edits to it take effect only after a rebuild.