Edit on GitHub

starplast.theme

Palettes, color maps and point styles.

Structured after spaCR's theme module so the two tools feel like one toolkit: a palette is a dict of named roles, every theme defines the same roles, and colors that are a function of the theme are derived rather than typed in. The rule that matters is spaCR's: a hex written into a widget is a hex that stays dark on the light theme, so nothing here hardcodes a color at a call site.

Four themes. dark and light are the general pair; slate is a low-contrast dark for long sessions staring at a 3D scatter, and paper is a high-contrast light intended for figures and screenshots.

Color maps are separated by what they are for, because using the wrong kind is the commonest way to mislead with a map of this sort:

sequential   an ordered quantity with a meaningful zero or floor (expression, pLDDT)
diverging    a quantity with a meaningful midpoint (fitness scores, log ratios, residuals)
categorical  unordered classes (compartment, cluster id, stage)

A diverging map on an unordered category invents an order; a sequential map on a residual hides its sign. CMAPS therefore records the kind, and the UI offers only the appropriate ones for a column.

DARK = {'bg': '#0b0d10', 'page': '#101317', 'surface': '#141820', 'surface_alt': '#1a1f28', 'surface_hi': '#222833', 'border': '#2b323d', 'border_soft': '#1c222b', 'fg': '#eef2f6', 'fg_muted': '#9aa6b4', 'fg_dim': '#68737f', 'accent': '#43c6d8', 'accent_hi': '#66dcea', 'accent_lo': '#2a9fb0', 'accent_soft': '#12303a', 'success': '#4bb96b', 'warning': '#d9a13a', 'error': '#e5615a', 'info': '#43c6d8'}
LIGHT = {'bg': '#f7f9fa', 'page': '#ffffff', 'surface': '#ffffff', 'surface_alt': '#f0f4f6', 'surface_hi': '#e6ecf0', 'border': '#d3dde3', 'border_soft': '#e8eef1', 'fg': '#131a20', 'fg_muted': '#53626e', 'fg_dim': '#7b8794', 'accent': '#0f7d8c', 'accent_hi': '#0b6b78', 'accent_lo': '#095a66', 'accent_soft': '#d9eef1', 'success': '#1f7a3d', 'warning': '#a8640a', 'error': '#b3261e', 'info': '#0f7d8c'}
SLATE = {'bg': '#16191d', 'page': '#1b1f24', 'surface': '#20252b', 'surface_alt': '#262c33', 'surface_hi': '#2e353d', 'border': '#39414a', 'border_soft': '#252b32', 'fg': '#dfe5ea', 'fg_muted': '#9aa4ae', 'fg_dim': '#6f7983', 'accent': '#c58f4a', 'accent_hi': '#dba765', 'accent_lo': '#a4733a', 'accent_soft': '#33281a', 'success': '#5a9e6a', 'warning': '#c9a227', 'error': '#cf6a63', 'info': '#c58f4a'}
PAPER = {'bg': '#ffffff', 'page': '#ffffff', 'surface': '#ffffff', 'surface_alt': '#f4f4f4', 'surface_hi': '#e9e9e9', 'border': '#c9c9c9', 'border_soft': '#e2e2e2', 'fg': '#000000', 'fg_muted': '#3c3c3c', 'fg_dim': '#6a6a6a', 'accent': '#0044cc', 'accent_hi': '#0036a3', 'accent_lo': '#002b80', 'accent_soft': '#dde6ff', 'success': '#0a6b2b', 'warning': '#8a5200', 'error': '#a30d0d', 'info': '#0044cc'}
THEMES = ('dark', 'light', 'slate', 'paper')
DARK_THEMES = ('dark', 'slate')
def palette_for(theme: str = 'dark') -> dict:

The color palette for a named theme.

def is_dark(theme: str) -> bool:

Whether a palette is a dark one, which decides contrast choices elsewhere.

def rgbf(hex_color: str, alpha: float = 1.0) -> tuple:

Hex to the 0-1 RGBA tuple pyqtgraph's GL items want.

def background(theme: str) -> tuple:

GL viewport background — the page color, not a literal, so themes stay coherent.

def unknown_color(theme: str) -> tuple:

The gray for 'unknown, not absent'. Derived from fg_dim so it reads on either ground.

CMAPS = {'viridis': ('sequential', 'viridis'), 'magma': ('sequential', 'magma'), 'plasma': ('sequential', 'plasma'), 'inferno': ('sequential', 'inferno'), 'cividis': ('sequential', 'cividis'), 'CET-L11': ('sequential', 'CET-L11'), 'CET-D1A': ('diverging', 'CET-D1A'), 'CET-D9': ('diverging', 'CET-D9'), 'CET-D4': ('diverging', 'CET-D4'), 'CET-D13': ('diverging', 'CET-D13'), 'CET-C7s': ('categorical', 'CET-C7s'), 'CET-C6': ('categorical', 'CET-C6')}
DEFAULT_CMAP = {'sequential': 'viridis', 'diverging': 'CET-D1A', 'categorical': 'CET-C7s'}
def resolve_cmap(name: str):

A pyqtgraph colormap, falling back rather than raising inside a paint call.

def cmaps_of(kind: str) -> list:

The color maps appropriate to one kind of data: sequential, diverging or categorical.

def kind_for_column(values) -> str:

Which family of color map a column deserves.

Diverging only when the data actually straddles a midpoint -- a residual or a score that goes both ways. Applying one to a strictly positive quantity invents a midpoint that is not there.

POINT_STYLES = {'small': {'size': 3.5, 'alpha': 0.85}, 'standard': {'size': 5.0, 'alpha': 0.95}, 'large': {'size': 8.0, 'alpha': 0.95}, 'halo': {'size': 9.0, 'alpha': 0.45}, 'pinpoint': {'size': 2.0, 'alpha': 1.0}}
DEFAULT_POINT_STYLE = 'standard'
DEPTH_FADE = 0.55
DEPTH_SHRINK = 0.45
EDGE_DEPTH_FADE = 0.18
def is_light(theme_or_palette) -> bool:

Whether a theme's ground is light. Decides which direction contrast has to go.

def depth_t(xyz, camera_pos, rng=None):

Distance from the camera, normalized to 0 (nearest) .. 1 (farthest).

rng is the (min, max) distance to normalize against. Pass the WHOLE cloud's range when cueing a subset -- edges touching a point must fade by the same amount as that point, and a subset normalized against its own extent does not agree with the full set at the same location.

def depth_cue(xyz, camera_pos, alpha, size, fade=0.55, shrink=0.45, rng=None):

Scale alpha and size by distance from the camera. Returns (alpha_array, size_array).

def categorical_colors(n: int, theme: str = 'dark', cmap: str | None = None) -> list:

n distinct colors for unordered classes, legible on this theme's ground.

A palette tuned for a dark background washes out on a light one -- the same hues that read as saturated against near-black read as pastel against near-white. Rather than ship two hand-tuned lists, the colors are darkened for light themes and lightened for dark ones, so any chosen categorical map stays legible on either ground.

POINT_MODES = ('occlude', 'additive')
def gl_options(mode: str) -> str:

pyqtgraph GL option set for a point mode.

additive is offered because density is sometimes what you want to see, but it is not the default: with 8,140 genes in dense UMAP clusters it saturates every color mode to white and destroys the encoding entirely.

GLASS_ALPHA = {'menu': 0.86, 'tooltip': 0.92, 'popup': 0.9, 'dialog': 0.9, 'pane': 0.35, 'bar': 0.45}
RADIUS = {'sm': 4, 'md': 8, 'lg': 10, 'card': 14}
FONT_FAMILY = '"Open Sans", "Segoe UI", "Helvetica Neue", sans-serif'
def glass_rgba(theme: str, role: str = 'menu', translucent: bool = True) -> tuple:

The glass colour for one role as an (r, g, b, a) tuple of floats in 0-1.

Black on a dark theme, white on a light one, at GLASS_ALPHA[role]. When the display cannot composite (translucent=False) the same pane is returned OPAQUE, as it would look laid over the theme's own background: a near-black on a dark theme rather than a black square with garbage in its corners.

def glass(theme: str, role: str = 'menu', translucent: bool = True) -> str:

The glass colour for one role, as a stylesheet colour (rgba(...), or #rrggbb when opaque).

def rim(theme: str, translucent: bool = True) -> str:

The hairline round a glass pane: a faint light edge on black glass, a faint dark one on white.

A '#aarrggbb' string, which both a stylesheet and QColor read. Opaque displays get the palette's own border, since a translucent edge over an opaque pane has nothing to show through.

def rgba(hex_color: str, alpha: float) -> str:

rgba(r, g, b, a) from a hex colour, for a panel that has to let the background through.

def stylesheet( theme: str = 'dark', container_opacity: float = 1.0, text_scale: float = 1.0, translucent: bool = True) -> str:

Qt stylesheet for one theme. Every color comes from the palette, never a literal.

container_opacity below 1 lets whatever is painted behind the window -- the drifting blob field -- show through the panels. Only the CONTAINERS take it: a translucent field would put moving colour behind text somebody is trying to read, and the point of the background is that it is behind things.

translucent says whether the display can composite translucent windows (see starplast.glass.compositing_available). Menus, tooltips, drop-down lists and glass windows are translucent black when it can, and the same colour made opaque when it cannot.

FIELD_GREY = '#2a2e35'
SWITCH_OFF = '#800080'
SWITCH_ON = '#008080'
class CheckList(PyQt6.QtWidgets.QListWidget):

A list of choices carried as TICKS, chosen by dragging across the rows and confirming with the right-click menu.

Selection and choice are separated deliberately, and that separation is the whole point. Both category lists in this program used the SELECTION as the choice, which has two costs a reader pays constantly: a set assembled over several ctrl-clicks is destroyed by one ordinary click, and there is no way to keep a set while clicking somewhere else to look at something. Here a drag selects, the context menu turns that selection into ticks, and the ticks survive every later click.

The one-item fast path is not lost: clicking an item's own box ticks it, which is Qt's behaviour for a checkable item and needs no menu. Space toggles the selected rows for the same reason.

checkedChanged fires ONCE for a bulk operation rather than once per row. The lists it feeds trigger a redraw of 8,140 points, and ticking forty categories should redraw once.

CheckList(parent=None)
def checkedChanged(unknown):

pyqtSignal(*types, name: str = ..., revision: int = ..., arguments: Sequence = ...) -> PYQT_SIGNAL

types is normally a sequence of individual types. Each type is either a type object or a string that is the name of a C++ type. Alternatively each type could itself be a sequence of types each describing a different overloaded signal. name is the optional C++ name of the signal. If it is not specified then the name of the class attribute that is bound to the signal is used. revision is the optional revision of the signal that is exported to QML. If it is not specified then 0 is used. arguments is the optional sequence of the names of the signal's arguments.

def add(self, label: str, value=None, **kw):

Append one checkable row. value is what checked returns for it, defaulting to label.

def items(self) -> list:

Every row, in list order. QListWidget offers no such accessor of its own.

def checked(self) -> list:

The ticked values, in list order. Empty is a meaningful answer -- both callers read it as 'all of them' -- so it is never padded into a full list here.

def set_checked(self, values, emit: bool = True) -> list:

Tick exactly these values and nothing else, as one change.

def keyPressEvent(self, event):

Space toggles every selected row, which is what a list of tick boxes should do.

def build_menu(self, at=None) -> PyQt6.QtWidgets.QMenu:

Construct the menu, and show it only when given somewhere to appear.

Split so a test can read the actions without entering the modal loop exec starts -- the same split as build_context_menu and build_preferences in the window.

class CheckTree(PyQt6.QtWidgets.QTreeWidget):

A tree of tick boxes where ticking a CATEGORY ticks everything under it.

The flat list this replaces had 96 rows, one per feature block, and at that length the list stops being a set of choices and becomes a scroll. What a reader actually wants to say is "all the transcription evidence", or "everything except the fitness screens" -- and both of those are statements about a LEVEL of the slot hierarchy, which already exists and already has the addresses.

Three properties make it usable for the thing it is for, which is choosing what a map is built on before holding something out of it:

  • Ticking a group ticks its descendants, and a group whose children are only partly ticked shows partially rather than lying in either direction.
  • checked() returns LEAVES only. A group is a way of saying something about its leaves, not a thing that can itself feed a map.
  • "Everything except this" is one action, because that is the hold-out question -- build the map on all the evidence but this, then ask whether this comes back -- and doing it by hand on 96 boxes is how a reader ends up holding out something they did not mean to.
CheckTree(parent=None)
def checkedChanged(unknown):

pyqtSignal(*types, name: str = ..., revision: int = ..., arguments: Sequence = ...) -> PYQT_SIGNAL

types is normally a sequence of individual types. Each type is either a type object or a string that is the name of a C++ type. Alternatively each type could itself be a sequence of types each describing a different overloaded signal. name is the optional C++ name of the signal. If it is not specified then the name of the class attribute that is bound to the signal is used. revision is the optional revision of the signal that is exported to QML. If it is not specified then 0 is used. arguments is the optional sequence of the names of the signal's arguments.

def build(self, tree: dict, label=None, enabled=None) -> int:

Fill from a nested dict: keys are names, empty values are leaves. Returns the leaf count.

def leaves(self, under=None) -> list:

Every leaf row, or every leaf beneath one node. Leaves are the blocks; groups are addresses.

def checked(self) -> list:

The ticked LEAVES, in tree order. A group is not itself an answer.

def set_checked(self, values, emit: bool = True) -> list:

Tick exactly these leaves and nothing else, as one change, skipping any that are disabled.

def build_menu(self, item=None, at=None) -> PyQt6.QtWidgets.QMenu:

Shown only when given somewhere to appear, so a test can read it without a modal loop.

class Switch(PyQt6.QtWidgets.QWidget):

A boolean slider, the shape and colors of spacr.gui_elements.spacrSwitch.

A checkbox would have done the job. This exists because the user runs both programs and a setting that looks like a setting in one of them should look like one in the other: 40x20 track, a 12-pixel knob that slides, purple for off and teal for on, caption on the left.

Switch(text: str = '', checked: bool = False, parent=None)
def toggled(unknown):

pyqtSignal(*types, name: str = ..., revision: int = ..., arguments: Sequence = ...) -> PYQT_SIGNAL

types is normally a sequence of individual types. Each type is either a type object or a string that is the name of a C++ type. Alternatively each type could itself be a sequence of types each describing a different overloaded signal. name is the optional C++ name of the signal. If it is not specified then the name of the class attribute that is bound to the signal is used. revision is the optional revision of the signal that is exported to QML. If it is not specified then 0 is used. arguments is the optional sequence of the names of the signal's arguments.

text
knob

pyqtProperty(type, fget=None, fset=None, freset=None, fdel=None, doc=None, designable=True, scriptable=True, stored=True, user=False, constant=False, final=False, notify=None, revision=0) -> property attribute

type is the type of the property. It is either a type object or a string that is the name of a C++ type. freset is a function for resetting an attribute to its default value. designable sets the DESIGNABLE flag (the default is True for writable properties and False otherwise). scriptable sets the SCRIPTABLE flag. stored sets the STORED flag. user sets the USER flag. constant sets the CONSTANT flag. final sets the FINAL flag. notify is the NOTIFY signal. revision is the REVISION. The other parameters are the same as those required by the standard Python property type. Properties defined using pyqtProperty behave as both Python and Qt properties. Decorators can be used to define new properties or to modify existing ones.

def isChecked(self) -> bool:

The current state.

def setChecked(self, on: bool) -> None:

Set the state, sliding the knob, without emitting.

def mouseReleaseEvent(self, ev):

Click anywhere on the control -- the track or the caption -- to toggle it.

def sizeHint(self):

Wide enough for the caption plus the 40-pixel track and its margin.

def track_color(self) -> str:

White on dark windows, as in spaCR; a mid grey on light ones, where white vanishes.

def paintEvent(self, _ev):

paintEvent(self, a0: QPaintEvent|None)

TOOLTIP_WIDTH = 64
def tip(text: str, width: int = 64) -> str:

A tooltip as one block of even lines, wrapped once and left that way.

Qt lays a plain tooltip out on a single line, so any real explanation becomes a strip wider than the screen. Inserting line breaks alone does not fix it: Qt re-wraps rich text at a width of its own choosing and strands two words on a row. A fixed table width does not constrain it either -- that was tried here and measured at 1,588 pixels, because Qt only wraps when it is given an explicit text width and a tooltip sets its own.

white-space: pre is what stops the second wrap: 1,188 pixels to 347 on a typical tooltip, with the breaks where they were put. Lines are padded to equal length so the block is a rectangle rather than a ragged edge; true justification is not in Qt's rich-text subset.

width is in characters, not pixels, because that is what the wrapping is done in.

def wrap_tooltips(root, width: int = 64) -> int:

Rewrite every tooltip under root as a wrapped block, and move it onto its label.

Two things, because they are the same complaint: a tooltip belongs on the SETTING, not on the box you type in. A form row is a label and a field, and hovering the label -- which is what names the thing and what the eye goes to first -- said nothing at all. Returns how many were rewritten, so a test can tell this ran.