"""Show sequencing reads as text, one read per row, with matches coloured.
A barcode mapping run claims three things about the sequencing files. The
first is that a barcode was found. The second is where that barcode sat.
The third is which direction it ran in.
A percentage on its own cannot prove that claim, because a percentage looks
the same whether the matches are real or accidental.
The reads themselves can prove it. When the guide barcode falls in the same
columns row after row, the number can be trusted. When nothing lines up, a
badly configured run shows itself at once, rather than after an hour of
mapping that returns no counts.
So this widget is deliberately plain. It draws the read text in a fixed pitch
face, one read to a line, and it paints the characters a barcode matched in a
colour belonging to that barcode type. Nothing wraps, nothing is elided, and
the character in column forty of one read sits directly above the character in
column forty of the next, which is the entire reason for looking at reads
rather than at a table.
This module knows nothing about how matches are found. It takes reads and
spans that somebody else computed, so the search engine and the screen that
hosts it can change freely without touching anything here, and so this can be
tested without either of them. The contract is the pair of small record types
below, and it is meant to be written by hand as easily as it is generated.
A note on colour, because it is the part that is easy to get wrong. Every
colour painted here is derived from the theme that is on screen, never written
as a literal, and the derivation preserves the relative luminance of the
theme's own accent role. Contrast is a function of relative luminance and of
nothing else, so a colour made this way clears exactly the contrast that the
accent already clears on that theme, including the themes whose panels are
translucent over a photograph. That is what lets an arbitrary number of
barcode types each get a colour of their own without any of them becoming
unreadable on some theme nobody checked.
"""
from __future__ import annotations
from typing import Dict, Iterable, List, NamedTuple, Optional, Sequence, Tuple
from PySide6.QtCore import QAbstractListModel, QEvent, QModelIndex, \
QPointF, QRectF, QSize, Qt
from PySide6.QtGui import QColor, QFont, QFontDatabase, QFontMetricsF, \
QPainter
from PySide6.QtWidgets import QAbstractItemView, QFrame, QListView, \
QSizePolicy, QStyle, QStyledItemDelegate, QVBoxLayout, QWidget
from ..theme import SPACING, active_palette, font_px, spaceout_palette
#: Degrees between one barcode type's hue and the next.
_GOLDEN_ANGLE = 137.50776405003785
#: Rows whose character ownership is kept resolved at once. Resolution is
#: lazy and a screenful is a few dozen rows, so this is loose enough never to
#: be hit by scrolling and tight enough that a long session cannot grow a
#: cache entry for every read in the file.
_RUN_CACHE_ROWS = 4096
#: Space left either side of the read text, in pixels.
_TEXT_PAD = 6
#: Extra height given to a row beyond the font's own line height.
_ROW_PAD = 2
[docs]
class BarcodeSpan(NamedTuple):
"""One stretch of a read that a barcode of some type matched.
The interval is half open and counted in characters from the start of the
read, so it follows Python slicing exactly and the matched text is
``read[start:stop]``. Positions outside the read are clamped when the span
is drawn, and a span whose stop is not after its start contributes
nothing, so a caller doing arithmetic on read lengths cannot make the view
raise.
The kind is an opaque label. It is whatever the caller calls that family
of barcode, it is what the legend shows, and it is what picks the colour,
so two spans sharing a kind are guaranteed to share a colour on every row.
:param start: First matched character, counting from zero.
:param stop: One past the last matched character.
:param kind: The barcode type this span belongs to.
"""
start: int
stop: int
kind: str
[docs]
class ReadRow(NamedTuple):
"""One read and everything that matched inside it.
:param text: The read as it should appear on screen, already trimmed to
whatever window the caller wants shown.
:param spans: The matches inside that text. Order matters when two of
them overlap, as described on :class:`ReadView`.
"""
text: str
spans: Tuple[BarcodeSpan, ...]
[docs]
def barcode_colours(kinds: Sequence[str]) -> Dict[str, str]:
"""Give each barcode type a colour of its own, taken from the theme.
Colours are assigned by position, so a type keeps its colour as long as
the caller keeps passing the types in the same order, and adding a type to
the end of the sequence never changes the colours already handed out.
Repeats are ignored after the first appearance.
The result follows the theme on screen at the moment of the call. It
differs between the light and dark palettes, and between both and the
image palettes, which is deliberate rather than incidental: each palette
has its own readable luminance and the colours are derived from it.
:param kinds: Barcode type names, in the order they should be coloured.
:returns: A mapping from each name to a hex colour string.
"""
accent = active_palette()["accent"]
out: Dict[str, str] = {}
for index, kind in enumerate(dict.fromkeys(str(k) for k in kinds)):
drift = (index * _GOLDEN_ANGLE) % 360.0
out[kind] = spaceout_palette({"accent": accent}, drift)["accent"]
return out
def _as_row(item: object) -> ReadRow:
"""Accept the several shapes a caller might reasonably pass for a read.
A bare string is a read with no matches, a two element sequence is a read
and its spans, and a :class:`ReadRow` is itself. Being generous here means
the integrating screen can hand over whatever its search engine already
produces instead of building records to suit this widget.
:param item: A read in any of the accepted shapes.
:returns: The equivalent :class:`ReadRow`.
"""
if isinstance(item, ReadRow):
return item
if isinstance(item, str):
return ReadRow(item, ())
text, spans = item
return ReadRow(str(text), tuple(spans))
def _as_span(item: object, length: int) -> Optional[BarcodeSpan]:
"""Clamp one span to a read, or reject it.
:param item: A :class:`BarcodeSpan` or any three element sequence holding
a start, a stop and a kind.
:param length: Characters in the read the span belongs to.
:returns: The clamped span, or ``None`` when it covers nothing.
"""
start, stop, kind = item
start = max(0, min(int(start), length))
stop = max(0, min(int(stop), length))
if stop <= start:
return None
return BarcodeSpan(start, stop, str(kind))
def _resolve_runs(text: str,
spans: Iterable[object]) -> Tuple[Tuple[int, int,
Optional[str]], ...]:
"""Work out which barcode type owns each character, then group them.
Ownership is decided before anything is drawn, and every character ends up
owned by exactly one type or by none, which is what makes overlapping
spans harmless. The first span in the sequence that covers a character
keeps it; a later span still colours whichever of its characters are
left over, so an overlapped match loses only the part that was taken and
stays visible for the rest.
:param text: The read being drawn.
:param spans: The matches inside it, most important first.
:returns: Consecutive runs as start, stop and owning type, covering the
whole read, where an owner of ``None`` means no barcode matched there.
"""
length = len(text)
if not length:
return ()
owner: List[Optional[str]] = [None] * length
for raw in spans:
span = _as_span(raw, length)
if span is None:
continue
for position in range(span.start, span.stop):
if owner[position] is None:
owner[position] = span.kind
runs: List[Tuple[int, int, Optional[str]]] = []
start = 0
while start < length:
here = owner[start]
stop = start + 1
while stop < length and owner[stop] == here:
stop += 1
runs.append((start, stop, here))
start = stop
return tuple(runs)
def _mono_font() -> QFont:
"""Build the fixed pitch font the reads are drawn in.
Asked for explicitly rather than inherited from the widget, because the
application stylesheet opens with a family rule that applies to every
widget and a stylesheet font beats one set in code. A widget that merely
calls ``setFont`` with the system fixed font therefore renders
proportionally and the columns stop lining up. Nothing here reads the
widget font: the font below is handed straight to the painter, where no
stylesheet can reach it.
:returns: A fixed pitch font at the user's current text size.
"""
font = QFontDatabase.systemFont(QFontDatabase.FixedFont)
font.setFamilies([font.family(), "DejaVu Sans Mono", "Menlo",
"Consolas", "Courier New", "monospace"])
font.setStyleHint(QFont.Monospace)
font.setFixedPitch(True)
font.setPixelSize(font_px("small"))
return font
class _ReadModel(QAbstractListModel):
"""Hold the reads and hand out one row at a time.
A list model rather than formatted text is the whole performance story.
Adding reads costs a list assignment, the view asks for the rows it can
actually show, and character ownership is resolved when a row is first
painted instead of when the reads arrive. Ten thousand reads therefore
cost the same to set as ten.
:param parent: Optional Qt owner responsible for the model's lifetime.
"""
def __init__(self, parent: Optional[QWidget] = None) -> None:
"""Start empty, with no reads and no colours."""
super().__init__(parent)
self._rows: List[ReadRow] = []
self._colours: Dict[str, str] = {}
self._runs: Dict[int, Tuple[Tuple[int, int, Optional[str]], ...]] = {}
self._widest = 0
def set_rows(self, rows: Sequence[object],
colours: Dict[str, str]) -> None:
"""Replace every read, and the colour each barcode type is drawn in.
:param rows: The reads, in any shape :func:`_as_row` accepts.
:param colours: Mapping from barcode type name to hex colour.
"""
self.beginResetModel()
self._rows = [_as_row(row) for row in rows]
self._colours = dict(colours)
self._runs.clear()
self._widest = max((len(row.text) for row in self._rows), default=0)
self.endResetModel()
def set_colours(self, colours: Dict[str, str]) -> None:
"""Repoint the colours without disturbing the reads or the scroll.
:param colours: Mapping from barcode type name to hex colour.
"""
self._colours = dict(colours)
def colour_for(self, kind: Optional[str]) -> Optional[str]:
"""Return the colour a barcode type is drawn in, if it has one.
:param kind: Barcode type name, or ``None`` for unmatched text.
:returns: A hex colour string, or ``None`` when the caller should use
the ordinary text ink.
"""
if kind is None:
return None
return self._colours.get(kind)
def widest(self) -> int:
"""Characters in the longest read, which sets the scrolling width.
:returns: The character count, or zero when there are no reads.
"""
return self._widest
def runs_for(self, row: int) -> Tuple[Tuple[int, int, Optional[str]], ...]:
"""Return the coloured runs of one read, resolving them on first ask.
:param row: Index of the read.
:returns: The runs, as described by :func:`_resolve_runs`.
"""
cached = self._runs.get(row)
if cached is not None:
return cached
if len(self._runs) >= _RUN_CACHE_ROWS:
self._runs.clear()
item = self._rows[row]
runs = _resolve_runs(item.text, item.spans)
self._runs[row] = runs
return runs
def resolved_rows(self) -> int:
"""How many reads have had their character ownership worked out.
This is the measurement behind the claim that showing the view does
not format every read. It is expected to stay near the number of rows
that fit on screen however many reads were handed over.
:returns: The number of rows currently resolved.
"""
return len(self._runs)
def rowCount(self, # noqa: N802
parent: QModelIndex = QModelIndex()) -> int: # noqa: B008
"""Return the number of reads for the root, and zero for children.
:param parent: Qt parent index whose child count is requested.
"""
return 0 if parent.isValid() else len(self._rows)
def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> object:
"""Return the read text, which is also its tooltip.
:param index: Model index identifying the read.
:param role: Qt data role; anything else returns ``None``.
"""
if not index.isValid():
return None
if role in (Qt.DisplayRole, Qt.ToolTipRole):
return self._rows[index.row()].text
return None
class _ReadDelegate(QStyledItemDelegate):
"""Paint one read, colouring the characters each barcode matched.
Painting rather than styling is what keeps the promise of one read per
row. Rich text would wrap, elide, and cost a formatting pass per read;
a painter draws exactly the characters asked for at exactly the character
positions asked for and does it only for the rows on screen.
Colour is not the only cue. A matched run also gets a rule drawn under it
in the same colour, so the match is still visible to a reader who cannot
separate two hues, and so a single matched character is noticeable at all.
:param parent: Optional Qt owner responsible for the delegate's lifetime.
"""
def __init__(self, parent: Optional[QWidget] = None) -> None:
"""Build the fixed pitch font and take the theme's text colours."""
super().__init__(parent)
self._font = _mono_font()
self._metrics = QFontMetricsF(self._font)
self._ink = "#ffffff"
self._selection = "#000000"
self.refresh_theme()
def refresh_theme(self) -> None:
"""Re-read the font size and the two theme colours this draws with.
Called when the theme or the text size changes. Resolving them here,
once, rather than inside the paint means a repaint never touches the
preference store.
"""
self._font = _mono_font()
self._metrics = QFontMetricsF(self._font)
palette = active_palette()
self._ink = palette["fg"]
self._selection = palette["accent_soft"]
def font(self) -> QFont:
"""Return the fixed pitch font reads are drawn in.
:returns: The font, which callers may measure but should not mutate.
"""
return QFont(self._font)
def row_height(self) -> int:
"""Return the height of one read row in pixels.
:returns: The line height of the fixed pitch font plus padding.
"""
return int(round(self._metrics.height())) + _ROW_PAD * 2
def advance(self) -> float:
"""Return the width of one character in the fixed pitch font.
:returns: The horizontal advance in pixels, never zero.
"""
return self._metrics.horizontalAdvance("0") or 8.0
def sizeHint(self, option, index) -> QSize: # noqa: N802
"""Return one row's size, wide enough for the longest read.
Every row is given the same width so that nothing wraps and the view
scrolls sideways instead. The view is told the sizes are uniform, so
this is asked once rather than once per read.
:param option: Qt style option for the item.
:param index: Model index of the item being measured.
"""
model = index.model()
widest = model.widest() if hasattr(model, "widest") else 0
width = int(round(self.advance() * max(widest, 1))) + _TEXT_PAD * 2
return QSize(width, self.row_height())
def paint(self, painter: QPainter, option, index) -> None:
"""Draw one read, run by run, in the colour each run's type owns.
:param painter: Painter supplied by the view.
:param option: Qt style option carrying the row rectangle and state.
:param index: Model index of the read being drawn.
"""
model = index.model()
text = index.data(Qt.DisplayRole) or ""
painter.save()
if option.state & QStyle.State_Selected:
painter.fillRect(option.rect, QColor(self._selection))
painter.setFont(self._font)
advance = self.advance()
rect = option.rect
baseline = (rect.top()
+ (rect.height() + self._metrics.ascent()
- self._metrics.descent()) / 2.0)
left = rect.left() + _TEXT_PAD
rule_top = baseline + self._metrics.descent() * 0.35
rule_height = max(1.0, round(self._metrics.descent() * 0.3))
runs = (model.runs_for(index.row())
if hasattr(model, "runs_for")
else ((0, len(text), None),))
for start, stop, kind in runs:
colour = (model.colour_for(kind)
if hasattr(model, "colour_for") else None)
painter.setPen(QColor(colour or self._ink))
painter.drawText(QPointF(left + start * advance, baseline),
text[start:stop])
if colour:
painter.fillRect(
QRectF(left + start * advance, rule_top,
(stop - start) * advance, rule_height),
QColor(colour))
painter.restore()
class _Legend(QWidget):
"""Name each colour, wrapping onto more lines when there are many types.
Painted rather than built from labels, for the same reason the reads are:
a per widget stylesheet would freeze the colours it was built with, and
these colours change with the theme. Everything here is resolved at paint
time, so a theme switch is a repaint.
:param parent: Optional Qt owner responsible for the legend's lifetime.
"""
def __init__(self, parent: Optional[QWidget] = None) -> None:
"""Start with nothing to explain, and therefore no height."""
super().__init__(parent)
self._entries: Tuple[Tuple[str, str], ...] = ()
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum)
def set_entries(self, entries: Sequence[Tuple[str, str]]) -> None:
"""Replace what the legend explains.
:param entries: Pairs of barcode type name and hex colour, in the
order they should be listed.
"""
self._entries = tuple((str(name), str(colour))
for name, colour in entries)
self.updateGeometry()
self.update()
def entries(self) -> Tuple[Tuple[str, str], ...]:
"""Return the pairs currently listed.
:returns: Pairs of barcode type name and hex colour.
"""
return self._entries
def _placements(self, width: int) -> Tuple[List[Tuple[float, float, str,
str]], int]:
"""Work out where each entry goes, and how tall that makes the legend.
:param width: The width the legend has to lay out inside.
:returns: The placements, as left edge, top edge, name and colour,
together with the total height in pixels.
"""
font = QFont()
font.setPixelSize(font_px("small"))
metrics = QFontMetricsF(font)
line = metrics.height()
swatch = line * 0.6
gap = SPACING["sm"]
step = line + SPACING["xs"]
placements: List[Tuple[float, float, str, str]] = []
x, y, tallest = 0.0, 0.0, line if self._entries else 0.0
for name, colour in self._entries:
span = swatch + SPACING["xs"] + metrics.horizontalAdvance(name)
if x and x + span > max(width, 1):
x, y = 0.0, y + step
tallest = y + line
placements.append((x, y, name, colour))
x += span + gap
return placements, int(round(tallest))
def hasHeightForWidth(self) -> bool: # noqa: N802
"""Report that the legend's height depends on how wide it is.
:returns: Always ``True``, because entries wrap onto further lines.
"""
return True
def heightForWidth(self, width: int) -> int: # noqa: N802
"""Return the height the entries need at a given width.
:param width: Width available to the legend, in pixels.
"""
return self._placements(width)[1]
def sizeHint(self) -> QSize: # noqa: N802
"""Return the legend's preferred size at its current width.
:returns: The current width paired with the height the entries need.
"""
return QSize(self.width() or 1, self.heightForWidth(self.width() or 1))
def minimumSizeHint(self) -> QSize: # noqa: N802
"""Return the smallest useful legend size.
:returns: A single line's height, so one entry always has room.
"""
return QSize(1, self._placements(1)[1])
def paintEvent(self, event) -> None: # noqa: N802
"""Draw a swatch and a name for every barcode type.
:param event: Qt paint event, whose region this ignores because the
legend is a handful of short items.
"""
if not self._entries:
return
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing, True)
font = QFont()
font.setPixelSize(font_px("small"))
painter.setFont(font)
metrics = QFontMetricsF(font)
line = metrics.height()
swatch = line * 0.6
for x, y, name, colour in self._placements(self.width())[0]:
ink = QColor(colour)
painter.fillRect(
QRectF(x, y + (line - swatch) / 2.0, swatch, swatch), ink)
painter.setPen(ink)
painter.drawText(
QPointF(x + swatch + SPACING["xs"], y + metrics.ascent()),
name)
painter.end()
[docs]
class ReadView(QWidget):
"""Reads as text, one to a row, with each barcode type in its own colour.
Hand it reads and the spans somebody else matched inside them, and it
shows them. It never searches for anything itself and never imports the
code that does, so it can be dropped into any screen that has reads and
positions to show.
What it guarantees. Reads are drawn in a fixed pitch face at fixed
character positions, so column forty of one read sits above column forty
of the next. A read never wraps onto a second visual row: a long one makes
the view scroll sideways instead, because a wrapped read would break the
one read per row promise that the whole widget exists to keep. A barcode
type keeps one colour for as long as the view is showing it, so the eye
can follow a match down the rows, and a legend above the reads says which
colour is which.
How overlaps are settled. The spans of a read are read in the order they
were given. The first span to cover a character keeps that character.
A later span still colours every character that is still free, so it is
trimmed rather than hidden. The rule is applied before anything is drawn,
which turns an overlap into a question of priority instead of a broken
picture. To let one barcode type win an overlap, list its spans first.
What it costs. The reads are held in a list model and drawn by a delegate,
so handing over reads costs a list assignment and drawing costs only the
rows that are actually on screen. Setting ten thousand reads and showing
the view resolves the character ownership of a screenful of rows, not of
ten thousand, which is what keeps the interface responsive on a real
FASTQ sample rather than on a toy one.
:param parent: Optional Qt owner responsible for the widget's lifetime.
"""
def __init__(self, parent: Optional[QWidget] = None) -> None:
"""Build an empty view with a legend above a list of reads."""
super().__init__(parent)
self._kinds: List[str] = []
self._colours: Dict[str, str] = {}
self._model = _ReadModel(self)
self._delegate = _ReadDelegate(self)
self._legend = _Legend(self)
self._list = QListView(self)
self._list.setModel(self._model)
self._list.setItemDelegate(self._delegate)
self._list.setUniformItemSizes(True)
self._list.setWordWrap(False)
self._list.setTextElideMode(Qt.ElideNone)
self._list.setWrapping(False)
self._list.setFlow(QListView.TopToBottom)
self._list.setResizeMode(QListView.Fixed)
self._list.setSelectionMode(QAbstractItemView.ExtendedSelection)
self._list.setHorizontalScrollMode(QAbstractItemView.ScrollPerPixel)
self._list.setVerticalScrollMode(QAbstractItemView.ScrollPerPixel)
self._list.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self._list.setFrameShape(QFrame.NoFrame)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(SPACING["xs"])
layout.addWidget(self._legend)
layout.addWidget(self._list, 1)
[docs]
def set_reads(self, rows: Sequence[object],
kinds: Optional[Sequence[str]] = None) -> None:
"""Show these reads, with these barcode types in the legend.
Passing the barcode types explicitly is worth doing whenever they are
known, because it fixes both the legend order and which type gets
which colour. A type that is searched for but found in none of the
reads on screen then still appears in the legend, which is itself
information: it says the search ran and came back empty rather than
leaving the user to wonder whether it ran at all.
Left unsaid, the types are taken from the spans in the order they are
first seen, and any type already being shown keeps the colour it has.
:param rows: The reads, each a :class:`ReadRow`, a plain string for a
read with no matches, or a text and spans pair.
:param kinds: Barcode type names in legend order, or ``None`` to take
them from the spans.
"""
prepared = [_as_row(row) for row in rows]
seen = list(self._kinds)
for name in (kinds if kinds is not None
else (span[2] for row in prepared for span in row.spans)):
name = str(name)
if name not in seen:
seen.append(name)
self._kinds = seen
self._colours = barcode_colours(self._kinds)
self._model.set_rows(prepared, self._colours)
self._legend.set_entries([(name, self._colours[name])
for name in self._kinds])
[docs]
def clear(self) -> None:
"""Drop every read and forget which barcode types were being shown.
Forgetting the types is the point of having this at all rather than
setting an empty list of reads. A fresh run may search for a different
set of barcodes, and carrying the previous run's assignment over would
give the new first barcode the old second barcode's colour.
"""
self._kinds = []
self._colours = {}
self._model.set_rows((), {})
self._legend.set_entries(())
[docs]
def row_count(self) -> int:
"""Return how many reads are being shown.
:returns: The number of rows in the view.
"""
return self._model.rowCount()
[docs]
def kinds(self) -> Tuple[str, ...]:
"""Return the barcode types being shown, in legend order.
:returns: The type names, which is also the order that assigned their
colours.
"""
return tuple(self._kinds)
[docs]
def colour_for(self, kind: str) -> Optional[str]:
"""Return the colour one barcode type is drawn in.
:param kind: The barcode type name.
:returns: A hex colour string, or ``None`` if the view has never been
shown that type.
"""
return self._colours.get(str(kind))
[docs]
def changeEvent(self, event) -> None: # noqa: N802
"""Follow a theme or text size change without being told about it.
The application re-applies its stylesheet and palette when the theme
changes, and Qt delivers that to every widget as a change event.
Taking the colours again here means the reads re-theme with everything
else instead of keeping the palette they were first drawn in.
:param event: The Qt change event being delivered.
"""
super().changeEvent(event)
if not hasattr(self, "_delegate"):
return
if event.type() in (QEvent.Type.StyleChange,
QEvent.Type.PaletteChange,
QEvent.Type.ApplicationPaletteChange,
QEvent.Type.FontChange):
self.refresh_colours()
[docs]
def refresh_colours(self) -> None:
"""Take the colours from the theme again and repaint.
Safe to call at any time and cheap enough to call on a whim: it
re-derives one colour per barcode type, not per read.
"""
self._delegate.refresh_theme()
if self._kinds:
self._colours = barcode_colours(self._kinds)
self._model.set_colours(self._colours)
self._legend.set_entries([(name, self._colours[name])
for name in self._kinds])
self._list.doItemsLayout()
self._list.viewport().update()
def _resolved_row_count(self) -> int:
"""Return how many reads have had their character ownership resolved.
Used by the tests that pin the laziness this widget depends on.
:returns: The number of resolved rows.
"""
return self._model.resolved_rows()