Aller au contenu

Panneaux, onglets, barre d'état et thème

Les surfaces d'interface auxquelles un plugin peut contribuer, au-delà des menus. Toutes suivent le même schéma : décrivez ce que vous voulez sous forme de données neutres vis-à-vis du backend, confiez une fabrique à l'hôte, conservez le jeton RAII renvoyé.

Panneaux ancrables

ctx.docks() ajoute un panneau latéral ancrable. La surface publique passe délibérément le widget sous forme de void* opaque — l'hôte basé sur Qt le convertit en QWidget* ; un plugin Python construit le même panneau de façon déclarative (voir la référence Python ctx.docks).

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

L'hôte instancie le panneau paresseusement (au premier affichage) et peut le réinstancier après une réinitialisation de la disposition — conservez l'état propre au panneau hors du widget ou reconstruisez-le dans create(). Le DockToken supprime le panneau à sa destruction.

Des pixels ou une part de la fenêtre — proportionalSize

0 (le défaut) est le comportement simple : le panneau conserve une taille en pixels, et un redimensionnement de la fenêtre le laisse tranquille. C'est ce que veut un panneau outil — un plan de document ou une liste de résultats est aussi utile à une largeur donnée quoi que fasse la fenêtre. Une valeur dans (0, 1) déclare au contraire une part par défaut de la fenêtre principale : de sa largeur dans les zones Left/Right, de sa hauteur dans Top/Bottom. C'est ce que veut un aperçu : maximisez la fenêtre et la page rendue s'élargit avec elle.

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

C'est un simple nombre précisément pour qu'un backend autre que C++ puisse remplir le même contrat (le backend Python le prend comme proportional_size). Ce que l'hôte garantit autour :

  • Vous ne fournissez que le défaut. Dès que l'utilisateur fait glisser le séparateur du panneau, l'hôte adopte la part qu'il a choisie, la mémorise avec la disposition de la fenêtre, et votre valeur ne s'applique plus jamais à ce panneau.
  • Les valeurs hors plage sont bornées, pas rejetées — la part est maintenue dans une fourchette raisonnable, et votre panneau comme l'éditeur gardent une étendue minimale, si bien qu'aucun des deux ne peut être réduit à rien. Une valeur négative ou non finie se lit comme 0.
  • Le minimum propre de votre panneau l'emporte. L'hôte ne demande jamais à la disposition moins que ce à quoi le widget peut être comprimé ; le panneau s'arrête à ce plancher sans oublier la part, et y revient quand la fenêtre s'élargit de nouveau. Un panneau destiné à être proportionnel doit donc pouvoir se réorganiser — une barre d'outils qui empile ses groupes au lieu d'exiger une seule rangée reste rétrécissable.
  • Les panneaux flottants et masqués sont ignorés. Un panneau flottant est sa propre fenêtre et garde sa taille ; un panneau masqué est dimensionné quand il est révélé, un panneau réancré quand il est de retour.
  • Les panneaux en onglets partagent un seul emplacement : c'est donc celui au premier plan qui le pilote — celui que l'utilisateur regarde est celui dont la part signifie quelque chose. Deux panneaux côte à côte sur un même axe partagent un budget unique et sont mis à l'échelle ensemble, en conservant leur rapport.

Un panneau enregistré avec initiallyVisible = false est révélé plus tard avec setPanelVisible — typiquement depuis la commande qui « l'ouvre ». L'afficher le remonte aussi au-dessus des docks qui partagent sa zone, pour qu'il arrive vraiment sous les yeux de l'utilisateur. L'appel est idempotent (répétez la même valeur librement) et un no-op silencieux pour un jeton qui ne vit plus — rien à suivre et aucun QDockWidget à aller chercher :

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

isPanelVisible indique si un panneau est ouvert dans la disposition de la fenêtre — l'état qu'écrit setPanelVisible et que bascule l'interrupteur ou le bouton de fermeture de l'utilisateur. Ce n'est pas « ses pixels sont-ils à l'écran » : un panneau en onglet derrière un autre est ouvert, un panneau enregistré masqué et jamais affiché ne l'est pas, pas plus qu'un jeton qui n'est plus vivant. La question ne crée jamais le panneau. Ensemble, les deux appels permettent à un plugin d'afficher ses panneaux le temps d'une tâche, puis de remettre exactement ce que l'utilisateur avait :

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

Des panneaux qui vont ensemble — tabGroup

Les panneaux qui indiquent le même tabGroup non vide et la même preferredArea partagent un seul panneau ancré, sous forme d'onglets, au lieu de se placer côte à côte — cinq panneaux de débogueur deviennent une seule bande d'onglets en bas de la fenêtre. Préfixez l'identifiant comme un identifiant de panneau ("myplugin/panels") ; le même identifiant dans deux zones forme deux piles, et un plugin peut rejoindre volontairement le groupe d'un autre plugin. Les onglets apparaissent dans l'ordre d'enregistrement ; le premier panneau ouvert est au premier plan jusqu'à ce que l'utilisateur ou setPanelVisible en fasse passer un autre devant.

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

Le groupe n'est que la disposition par défaut. Une disposition enregistrée par l'utilisateur l'emporte : un panneau que l'utilisateur a sorti de la pile, déplacé vers une autre zone ou dans une fenêtre flottante y reste d'un redémarrage à l'autre. Un panneau que la disposition enregistrée ne connaît pas encore — nouveau dans cette version de votre plugin — rejoint son groupe.

Types d'onglets de première classe

Quand une fonctionnalité mérite son propre type d'onglet (résultats de diff, vue de log, visionneuse hexadécimale) plutôt qu'un tampon texte ou un panneau latéral, enregistrez un type d'onglet auprès de 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 */});

L'hôte place votre IDocumentTab dans la barre d'onglets aux côtés des onglets d'édition et lui achemine les opérations d'enregistrement/fermeture. openTab() renvoie un handle DocumentTabId (0 si rien ne s'est ouvert — type inconnu, fabrique ayant renvoyé null) ; les identifiants sont stables pour la vie de l'onglet et jamais réutilisés au cours d'une exécution, si bien qu'un handle périmé se lit comme « fermé » et jamais comme l'onglet de quelqu'un d'autre. Gardez-le pour suivre l'onglet ensuite, ou ignorez-le pour une ouverture « lancer et oublier » :

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

L'idiome « ouvrir ou révéler » est activateTab() quand isTabOpen() dit que le handle est encore vivant, sinon openTab(). Les onglets qui renvoient un sessionParams() non vide sont capturés par la persistance de session et rejoués via openTab() à la restauration ; si le plugin propriétaire est absent au moment de la restauration, l'hôte regroupe les entrées orphelines dans un seul message au lieu de les abandonner. Exemples internes : Diff Compare et le Log Analyzer.

La vue de comparaison

ctx.compare() ouvre la fenêtre de comparaison à deux volets de l'éditeur pour n'importe quelle paire de sources — chemins de fichiers, documents ouverts ou texte en mémoire :

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

Toujours sûr à appeler : un hôte sans vue de comparaison fournit un no-op qui renvoie false.

Barre d'état

Deux canaux indépendants — notifications transitoires et segments persistants possédés par le plugin :

// 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

Une notification de sévérité supérieure remplace immédiatement celle à l'écran ; une notification de sévérité inférieure peut être brièvement retenue afin qu'une Error ne soit pas aussitôt effacée par une Info de routine. Rafraîchir un segment n'écrase jamais une notification, et inversement.

Menu contextuel de l'éditeur

Contrairement à IMenuRegistry — des entrées statiques de la barre de menus liées à des commandes — les éléments du menu contextuel sont dynamiques : à chaque clic droit dans la zone de texte — ou dans la marge à côté —, l'hôte demande à chaque fournisseur enregistré ce qui s'applique à l'endroit cliqué et ajoute les réponses sous les actions d'édition intégrées (Annuler/Rétablir, Couper/Copier/Coller, Tout sélectionner). La requête indique où a eu lieu le clic :

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 vaut Margin pour un clic dans n'importe quelle colonne à gauche du texte (numéros de ligne, colonne des signets, colonne de repli) et Text sinon ; un menu ouvert au clavier est toujours Text.
  • line est la ligne cliquée (la ligne du curseur pour un menu au clavier) : vous n'avez donc jamais à convertir position vous-même.
  • position est le décalage en octets sous le clic dans le texte (le curseur pour un menu au clavier). Un clic dans la marge n'a aucun caractère au-dessous de lui ; position y est donc le décalage du début de line — valide, mais n'en déduisez jamais une colonne.

Décidez par zone de ce que vous proposez : les entrées de niveau ligne (Add Conditional Breakpoint…, Revert Hunk) relèvent de la marge, celles de niveau mot du texte. Le correcteur orthographique, par exemple, ne propose rien pour un clic dans la marge.

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

Les fournisseurs s'exécutent sur le thread UI pendant que l'utilisateur attend le menu — répondez à partir d'un état en mémoire, ne bloquez jamais. Les titres des éléments sont des chaînes dynamiques que le plugin traduit lui-même. Le correcteur orthographique s'en sert pour ses suggestions et ses entrées Ajouter au dictionnaire / Ignorer pour cette session.

Palette de thème

Les plugins qui peignent des couleurs personnalisées enregistrent des catégories nommées avec des valeurs par défaut claire/sombre et les résolvent en RGB au moment du dessin — ne codez jamais les couleurs en dur :

// 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 renvoie la surcharge du thème quand elle existe, sinon la valeur par défaut enregistrée pour la variante active, et 0x000000 pour un id non enregistré (traitez cela comme « utiliser mon propre repli »).

Chaque catégorie enregistrée apparaît automatiquement dans l'éditeur de thèmes, et les surcharges de l'utilisateur font l'aller-retour dans le fichier de thème sous pluginColors. Repeignez sur events::ThemeChanged.