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

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().

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

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;
};
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.

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.
  • 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.