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 neverCtrl+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).
Menus¶
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;
};
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.
Menu placement (plugin.json)¶
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:
title— the top-level menu; matches the first/-separated segment of yourmenuPath. Give it a keyboard mnemonic with&("Lo&g Analyzer") and use the same spelling in everymenuPath: 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 withintitle(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.