Skip to content

Docks, tabs, status bar & theme

The UI surfaces a plugin can contribute to, beyond menus. All of them follow the same pattern: describe what you want in backend-neutral data, hand the host a factory, keep the returned RAII token.

Dock panels

ctx.docks() adds a dockable side panel. The public surface deliberately passes the widget as an opaque void* — the Qt-backed host casts it to QWidget*; a Python plugin builds the same panel declaratively instead (see the Python ctx.docks reference).

class IDockPanel {
public:
    void*       nativeWidget();   // your QWidget*, host takes ownership
    std::string title() const;
};

class IDockPanelFactory {
public:
    std::unique_ptr<IDockPanel> create();
};

struct DockPanelDescriptor {
    std::string id;                            // "myplugin.outline"
    std::string defaultTitle;
    DockArea    preferredArea    = DockArea::Right;   // Left/Right/Top/Bottom
    bool        initiallyVisible = true;              // false = created hidden
    double      proportionalSize = 0.0;                // 0 = pixel-sized
    std::string tabGroup;                              // "" = a dock of its own
};

m_dock = ctx.docks().addPanel(descriptor, std::move(factory));

The host instantiates the panel lazily (on first display) and may re-instantiate it after a layout reset — keep per-panel state outside the widget or rebuild it in create(). The DockToken removes the panel on destruction.

Pixels or a share of the window — proportionalSize

0 (the default) is the plain behaviour: the panel holds a pixel size and a window resize leaves it alone. That is what a tool panel wants — an outline or a results list is as useful at one width whatever the window does. A value in (0, 1) instead declares a default share of the main window: of its width in the Left/Right areas, of its height in Top/Bottom. That is what a preview wants — maximise the window and the rendered page widens with it.

desc.proportionalSize = 0.40;   // open at 40 % of the window

It is a plain number precisely so a non-C++ backend can fulfil the same contract (the Python backend takes it as proportional_size). What the host guarantees around it:

  • You supply only the default. The moment the user drags the panel's separator, the host adopts the share they chose, remembers it with the window layout, and your value never applies to that panel again.
  • Out-of-range values are clamped, not rejected — the share is held inside a sane band, and both your panel and the editor keep a minimum extent, so neither can be collapsed. A negative or non-finite value reads as 0.
  • Your panel's own minimum wins. The host never asks the layout for less than the widget can be squeezed into; the panel stops at that floor without forgetting the share, and returns to it when the window is widened again. A panel meant to be proportional should therefore be able to reflow — a toolbar that stacks its groups instead of demanding one row keeps the panel narrowable.
  • Floating and hidden panels are skipped. A floating panel is its own window and keeps its size; a hidden one is sized when it is revealed, a re-docked one when it is back.
  • Tabified panels share one slot, so the panel in front drives it — the one the user is looking at is the one whose share means anything. Two panels side by side on one axis share a single budget and are scaled together, keeping their ratio.

A panel registered with initiallyVisible = false is revealed later with setPanelVisible — typically from the command that "opens" it. Showing also raises the panel above the docks it shares an area with, so it really ends up in front of the user. The call is idempotent (repeat the same value freely) and a silent no-op for a token that is no longer live, so there is nothing to track and no QDockWidget to reach for:

ctx.docks().setPanelVisible(m_dock.id(), true);    // reveal + raise
ctx.docks().setPanelVisible(m_dock.id(), false);   // hide, keep registered

isPanelVisible answers whether a panel is open in the window layout — the state setPanelVisible writes and the user's toggle or close button flips. It is not "are its pixels on screen": a panel tabbed behind another is open, a panel registered hidden and never shown is not, and neither is a token that is no longer live. Asking never creates the panel. Together the two calls let a plugin show its panels for the length of a task and put back exactly what the user had:

const bool wasOpen = ctx.docks().isPanelVisible(m_dock.id());
ctx.docks().setPanelVisible(m_dock.id(), true);
// ... when the task ends:
ctx.docks().setPanelVisible(m_dock.id(), wasOpen);

Panels that belong together — tabGroup

Panels that give the same non-empty tabGroup and the same preferredArea share one dock as tabs instead of sitting side by side — five debugger panels become one tabbed strip at the bottom of the window. Namespace the id like a panel id ("myplugin/panels"); the same id in two areas makes two stacks, and a plugin may join another plugin's group on purpose. Tabs appear in registration order; the first open panel is in front until the user or setPanelVisible raises another.

desc.preferredArea = DockArea::Bottom;
desc.tabGroup      = "myplugin/panels";

The group is only the default arrangement. A layout the user saved wins: a panel the user dragged out of the stack, into another area or into a floating window stays there across restarts. A panel the saved layout does not know yet — new in this version of your plugin — joins its group.

First-class tab types

When a feature deserves its own kind of tab (diff results, a log view, a hex viewer) rather than a text buffer or a side panel, register a tab type with ctx.documents():

struct DocumentTabTypeDescriptor {
    std::string typeId;         // "log-view" -- lowercase-with-hyphens
    std::string displayName;    // localized type name
    DefaultPlacement defaultPlacement;  // ActiveGroup / NewGroupRight / NewGroupBottom
};

m_type = ctx.documents().registerType(descriptor, std::move(factory));
DocumentTabId tab = ctx.documents().openTab("log-view", {/* params */});

The host places your IDocumentTab in the tab strip alongside editor tabs and routes save/close to it. openTab() returns a DocumentTabId handle (0 when nothing opened — unknown type, null factory result); ids are stable for the tab's lifetime and never reused within a run, so a stale handle reads as closed rather than as somebody else's tab. Keep it to follow the tab afterwards, or discard it for a fire-and-forget open:

bool open = ctx.documents().isTabOpen(tab);   // false once the user closed it
ctx.documents().activateTab(tab);             // raise + focus (no-op if closed)
bool closed = ctx.documents().closeTab(tab);  // honours canClose(); false on veto

The "open or reveal" idiom is activateTab() when isTabOpen() says the handle is still live, else openTab(). Tabs that return non-empty sessionParams() are captured by session persistence and replayed via openTab() on restore; if the owning plugin is missing at restore time, the host rolls the orphaned entries into a single message instead of dropping them. First-party examples: Diff Compare and the Log Analyzer.

The compare view

ctx.compare() opens the editor's two-pane compare window for any pair of sources — file paths, open documents, or in-memory text:

CompareSource left{...}, right{...};
bool shown = ctx.compare().openCompare(left, right, CompareOptions{});

Always safe to call: a host without a compare view supplies a no-op that returns false.

Status bar

Two independent channels — transient notifications and persistent, plugin-owned segments:

// Transient: replaces/queues by severity; do NOT use for live state.
ctx.statusBar().showMessage("Wrapped", 2000, StatusLevel::Info);

// Persistent segment: an always-on indicator you own and refresh.
m_segment = ctx.statusBar().addSegment("wordcount.total");
m_segment->setText("2,431 words");
m_segment->setVisible(false);        // hide without destroying
// dropping the unique_ptr removes the segment

A higher-severity notification replaces the one on screen immediately; a lower-severity one may be held back briefly so an Error isn't instantly wiped by routine Info. Refreshing a segment never clobbers a notification and vice versa.

Editor context menu

Unlike IMenuRegistry — static menu-bar entries bound to commands — context-menu items are dynamic: on every right-click in the text area — or in the margin beside it — the host asks each registered provider what applies at the clicked place and appends the answers below the built-in edit actions (Undo/Redo, Cut/Copy/Paste, Select All). The request says where the click was:

enum class ContextMenuArea : std::int32_t { Text = 0, Margin = 1 };

struct ContextMenuRequest {
    DocumentId      doc;
    std::int64_t    position; // byte offset; the start of `line` for Margin
    std::int64_t    line;     // 0-based
    ContextMenuArea area;
};
  • area is Margin for a click in any column left of the text (line numbers, the bookmark column, the fold column) and Text otherwise; a menu opened from the keyboard is always Text.
  • line is the clicked line (the caret's line for a keyboard menu), so you never have to convert position yourself.
  • position is the byte offset under the click in the text (the caret for a keyboard menu). A margin click has no character under it, so there position is the offset of the start of line — valid, but never read a column out of it.

Decide per area what you offer: line-level entries (Add Conditional Breakpoint…, Revert Hunk) belong to the margin, word-level ones to the text. The spell checker, for one, offers nothing for a margin click.

class MyProvider final : public plugin::IContextMenuProvider
{
    std::vector<plugin::ContextMenuItem>
    contextMenuItems(const plugin::ContextMenuRequest& req) override
    {
        // Word-level entries: text clicks only. Empty vector = decline.
        if (req.area != plugin::ContextMenuArea::Text) { return {}; }
        if (!appliesAt(req.doc, req.position)) { return {}; }
        return {
            { "Do the thing", [this, req] { doTheThing(req); } },
            { "", {} },                       // empty title = separator
            { "Another action", [this] { another(); } },
        };
    }
};

m_token = ctx.contextMenu().registerProvider(m_provider); // RAII token

Providers run on the UI thread while the user waits for the menu — answer from in-memory state, never block. Item titles are dynamic strings the plugin translates itself. The spell checker uses this for its suggestions / Add to Dictionary / Ignore for This Session entries.

Theme palette

Plugins that paint custom colours register named categories with light/dark defaults and resolve them to RGB at paint time — never hard-code colours:

// ThemeCategory: {id, displayLabel, defaultLight, defaultDark}.
// The id is namespaced "<plugin.id>/<role>" by convention.
m_cat = ctx.themePalette().registerCategory(
    {"com.example.myplugin/match", "Match highlight",
     /*light*/ 0xFFF3B0, /*dark*/ 0x8A6D00});

std::uint32_t rgb = ctx.themePalette().resolveColor("com.example.myplugin/match");
bool themed       = ctx.themePalette().hasOverride("com.example.myplugin/match");
std::string variant = ctx.themePalette().activeVariant();  // "light" / "dark" / ""

resolveColor returns the theme's override when present, else the registered default for the active variant, and 0x000000 for an unregistered id (treat that as "use my own fallback").

Every registered category automatically appears in the Theme Editor, and user overrides round-trip in the theme file under pluginColors. Repaint on events::ThemeChanged.