Plexora

Build a custom plugin

Add your own tool to Plexora: a Python package with a descriptor, a Flask route, a panel and a browser script.

A plugin adds a tool to the Tools menu with its own card in the sidebar. This page builds a small one, Cell Count, that shows the project's name and how many channels its image has, then tests and packages it.

How plugins work

A plugin is an installable Python package that exposes one PLUGIN = Plugin(...) descriptor. Plexora finds it through the plexora.plugins entry point and mounts its Flask Blueprint under /plugins/<name>/. When you open the tool, Plexora puts the plugin's panel template in a card and loads its scripts. The script registers itself with window.Plexora.registerPlugin and fills the panel from the plugin's own routes. Python code imports from plexora.api and nowhere else in Plexora; everything else is internal and can change between releases.

The files

pyproject.toml
__init__.py
__init__.py
routes.py
panel.html
cellCount.js

Describe the plugin

cell_count/__init__.py
from plexora.api import Plugin, Requires

VERSION = "1"


def _blueprint():
    # Imported here, so reading the descriptor never loads the routes.
    from cell_count.server.routes import cell_count_bp

    return cell_count_bp


PLUGIN = Plugin(
    name="cell_count",
    label="Cell Count",
    version=VERSION,
    blueprint_factory=_blueprint,
    icon="hashtag",
    panels={"tool_panel_slot": "cell_count/panel.html"},
    scripts=("cellCount.js",),
    requires=Requires(),
)

name must match the package name and the entry point. Requires() with nothing set means the tool works on any project; Requires(table=True, roles=("cell_id",)) would make Plexora ask for a cell table and its cell id column first. Every field: Plugin, Requires.

Add a route

cell_count/server/routes.py
from flask import Blueprint, request

from plexora.api import json_response, project_data

cell_count_bp = Blueprint(
    "cell_count", __name__,
    template_folder="../templates",
    static_folder="../static",
    static_url_path="/static",
)


@cell_count_bp.route("/summary")
def summary():
    data = project_data(request.args["datasource"])
    return json_response({
        "project": data.name,
        "channels": data.image.channel_names,
    })

project_data reads a project's image, mask, cell table and column roles; it raises KeyError for an unknown project. json_response serialises numpy values that Flask's jsonify refuses. See project_data.

Add the panel

cell_count/templates/cell_count/panel.html
<section class="sidebar-section">
    <div class="control-row">
        <span class="control-label">Project</span>
        <span id="cc_project"></span>
    </div>
    <div class="control-row">
        <span class="control-label">Channels</span>
        <span id="cc_channels"></span>
    </div>
</section>

The template is only the card's body; Plexora draws the header (grip, name, ?, eye, ×). Keep templates in a folder named after the plugin, so two plugins can both ship a panel.html.

Register the script

cell_count/static/cellCount.js
window.Plexora.registerPlugin({
    name: "cell_count",
    help: {
        summary: "Shows the project's name and how many channels its image has.",
        docs: "plugins/build-a-plugin",
    },
    createSidebarController(ctx) {
        const url = (path) =>
            `${ctx.url(`plugins/cell_count/${path}`)}?datasource=${encodeURIComponent(ctx.datasource)}`;
        return {
            async setup() {
                const data = await (await fetch(url("summary"))).json();
                document.getElementById("cc_project").textContent = data.project;
                document.getElementById("cc_channels").textContent = data.channels.length;
            },
        };
    },
});

ctx.url() adds the base path when Plexora runs behind a proxy, so always build request URLs with it. help is what the ? on the card shows (summary, optional notes and shortcuts). Register cleanup with ctx.onCleanup(fn). See PluginDefinition and PluginContext.

Run it

pyproject.toml
[project]
name = "cell-count"
version = "0.1.0"
dependencies = ["plexora"]

[project.entry-points."plexora.plugins"]
cell_count = "cell_count:PLUGIN"

[build-system]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"

[tool.setuptools.package-data]
cell_count = ["templates/**/*", "static/*"]

Install it into the same environment as Plexora and start Plexora:

pip install -e .
plexora

Cell Count is now in the Tools menu. Installing a plugin is what turns it on. plexora --plugins cell_count (or PLEXORA_PLUGINS=cell_count) starts Plexora with only the plugins you list. See the plexora command.

Design the sidebar panel

Every tool panel in the sidebar uses the same measures, so several open tools read as one sidebar. Plexora provides them as CSS variables (tokens.css) and shared classes. Use those rather than writing the numbers out. Cell Explorer is the reference panel.

Space

  • Plexora pads the card body, including the colour stripe on its left edge, so the left and right insets match. Don't add horizontal padding to your section.
  • Stack the panel's blocks with gap: var(--panel-gap) (8px) on the section, not with margins on each block.
  • Separate the panel's parts with a 1px var(--panel-rule) hairline. Leave the same space above and below it, measured from the text on each side to the rule.
  • Don't put groups in boxes. Rules and space do that job.

Type. Use four sizes:

VariableSizeFor
--panel-font-body13pxNames, values, the select, list rows
--panel-font-meta12pxCounts, summaries, text buttons, nested rows
--panel-font-note11pxCaptions (.control-label), status lines, hints
--panel-font-footer10pxThe footer

Sizes

VariableSizeFor
--panel-control-height34pxA select, a text field, a row that opens another tool
--panel-button-height28pxA line of buttons, or a segmented control, on its own line
--panel-icon-button24pxAnything inside a row: eye, menu and close buttons, a header's Run, a row's Approve. 13px glyph for the eye, 11px for the rest
--panel-row-height32pxA list row. A row nested under it is 28px

Buttons and segmented controls use 11px text.

Corners. Two radii, and nothing else on a control or a card:

  • --radius-control (8px): every button, field, select, segmented control and icon button, the hover background of a row, and any box inside a card (a plot, a notice, a form).
  • --radius-card (12px): the card itself. Plexora draws the cards.
  • Shapes keep their own: dots and colour swatches are round, count badges and progress bars are pills, and key caps keep their small corner. Don't draw a button as a pill.

Fields and buttons

  • Give selects and text fields the plx-panel-field class: on a SearchableSelect's mount, on an <input>, or on a <select>. It sets the 34px height, a faint background, a 1px border and the control radius. On focus the border turns blue, with no glow.
  • Rows and icon buttons show --panel-hover-ground under the pointer.
  • Buttons that act on the whole panel (import, export) are Plexora's .layer-card-action buttons in a .layer-card-actions line. Choices of one from a few are Plexora's .cell-mode-options segmented control.

Layout

  • Start with the panel's main choice: a .control-label caption and its field, either above it or beside it in a .control-row.
  • Put a visibility eye (far fa-eye / far fa-eye-slash) at the far right of every row, so the eyes form one column. A show-all or hide-all eye goes in the same column, on the heading.
  • Right-align counts, at the meta size, in the muted text colour.
  • Put actions that take the user out of the panel (export, another tool) below the list, after a rule.

Footer. Every panel ends with a footer that lists its keyboard shortcuts:

<footer id="cc_footer" class="plx-panel-footer" aria-label="Previous and next channel">
    <button type="button" class="plx-panel-shortcut" data-step="-1"
            title="Previous channel (Z)"><kbd class="plx-kbd">Z</kbd> Previous channel</button>
    <button type="button" class="plx-panel-shortcut" data-step="1"
            title="Next channel (X)"><kbd class="plx-kbd">X</kbd> Next channel</button>
</footer>
  • Each shortcut is a button, and clicking it does what the key does. Disable it when the key would do nothing, such as at the end of a list.
  • Print a chord such as Mod+Z with PlexoraShortcuts.format("Mod+Z"), so it reads ⌘Z on a Mac and Ctrl+Z elsewhere.
  • Act on a single-letter key only while your tool is the active one (PlexoraToolLoader.activeTool()) and nobody is typing (PlexoraShortcuts.isTyping()).
  • List the same keys in help.shortcuts, so the card's ? shows them too.

Keep state

To remember something per project, use the plugin's own store. Its tables are namespaced to your plugin:

from plexora.api import store

saved = store(request.args["datasource"], "cell_count")
saved.put_state(b"opened")          # opaque bytes
saved.put_table("results", frame)   # a polars DataFrame

If a route changes a project behind the open tabs' backs, notify_viewers(project, "cell_count", kind) tells them to redraw. See store and notify_viewers.

Test it

A plugin's routes are ordinary Flask routes, so Flask's test client calls them without a browser:

tests/test_cell_count.py
import numpy as np
import tifffile

import plexora


def test_summary(tmp_path, monkeypatch):
    monkeypatch.setenv("PLEXORA_DATA_PATH", str(tmp_path / "data"))
    image = tmp_path / "image.tif"
    tifffile.imwrite(image, np.zeros((3, 64, 64), np.uint16), photometric="minisblack")
    plexora.import_sample(image, name="demo", wait=True)

    client = plexora.app.test_client()
    reply = client.get("/plugins/cell_count/summary?datasource=demo")

    assert reply.status_code == 200
    assert len(reply.get_json()["channels"]) == 3

Setting PLEXORA_DATA_PATH to a temporary folder keeps the test away from your real projects. See the Python API for import_sample.

Package and share it

Build a wheel with python -m build and publish it like any Python package; anyone who installs it next to Plexora gets the tool. Plugins built on the documented plugin API are yours: Plexora's licence lets you distribute and license them on your own terms. Bump VERSION with every release, since it is what makes browsers fetch your new scripts.

Edit this page on GitHub

On this page