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
Describe the plugin
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
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
<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
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
[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 .
plexoraCell 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:
| Variable | Size | For |
|---|---|---|
--panel-font-body | 13px | Names, values, the select, list rows |
--panel-font-meta | 12px | Counts, summaries, text buttons, nested rows |
--panel-font-note | 11px | Captions (.control-label), status lines, hints |
--panel-font-footer | 10px | The footer |
Sizes
| Variable | Size | For |
|---|---|---|
--panel-control-height | 34px | A select, a text field, a row that opens another tool |
--panel-button-height | 28px | A line of buttons, or a segmented control, on its own line |
--panel-icon-button | 24px | Anything 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-height | 32px | A 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-fieldclass: on aSearchableSelect'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-groundunder the pointer. - Buttons that act on the whole panel (import, export) are Plexora's
.layer-card-actionbuttons in a.layer-card-actionsline. Choices of one from a few are Plexora's.cell-mode-optionssegmented control.
Layout
- Start with the panel's main choice: a
.control-labelcaption 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+ZwithPlexoraShortcuts.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 DataFrameIf 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:
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"]) == 3Setting 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.