"""Live DNA rain — a Matrix-style ATGC cascade painted *behind* a screen.
This is the Qt descendant of :func:`spacr.gui_elements.generate_dna_matrix`,
which renders the same idea offline to a GIF/MP4. The ideas carried over
from it are the ones that make the effect read as "rain" rather than as a
scrolling table:
* every column picks its own string length (10-100 glyphs, capped so
it cannot dwarf the canvas — see :data:`MAX_STRING_SCREENS`),
* every column starts at its own row *above* the canvas, so columns do
not enter in lockstep,
* the leading glyph is drawn in a different colour from the trail,
* a short run of glyphs inside each string is highlighted.
Three deliberate departures:
``ATGC only``
The offline renderer had a ``lowercase_prob`` that mixed ``a/t/g/c``
in. Here the alphabet is exactly ``A``, ``T``, ``G``, ``C`` — plus
the ``spaCR`` splice below.
``Live, not rendered``
Nothing is written to disk. :class:`DnaRainWidget` paints frames in
real time, driven by a capped timer that *stops whenever the widget
is not on screen* — this sits behind the sequencing pipeline, which
is doing real work, so it must not cost a core.
``The tail fades, not the head``
The offline version faded the glyphs nearest the head. That reads
backwards; here the trailing (upper) end of each string fades out.
The ``spaCR`` splice
--------------------
Very infrequently a column carries the literal word ``spaCR``, spelled down
the column one letter per cell, exactly like the bases around it::
s
p
a
C
R
The casing is load-bearing; the orientation is what makes it part of the rain.
It used to be stored as a single token in a single cell and drawn
horizontally, overflowing rightwards across its neighbours — which read as a
label pasted over the effect rather than as part of it, and cost the renderer
a whole second paint pass, measured token widths, widened dirty rectangles and
a cleared backing rectangle so neighbouring glyphs did not tangle with the
letters. None of that machinery exists now: every cell holds exactly one
character, so the word is cached and blitted like any other glyph and cannot
overdraw anything. See :data:`SPACR_SPLICE_PROBABILITY` for the rate and what
it works out to in practice.
Legibility
----------
Screen content sits in front of this widget. It therefore never takes
focus, is transparent to mouse events, lowers itself to the bottom of
the sibling stacking order, and paints its glyphs at
:data:`DEFAULT_OPACITY` over the theme background so anything in front
of it stays readable.
Settings
--------
Colour (fixed, or a random hue per column), speed, visibility and font
size, all live. They are **not** on the screen: they live in a popover
behind a ``DNA`` button beside the ``AI`` toggle — see
:mod:`spacr.qt.widgets.dna_rain_settings`. :class:`DnaRainSettingsBar`
is the panel itself, which lays out either as that popover's grid or as
the original single row.
Cost
----
This runs behind the sequencing pipeline, so it has to be close to
free. Three things get it there, and all three were measured rather
than assumed (1920x1080, 120 columns, 67 rows):
1. *The timer stops whenever the widget is not on screen* — hidden, or
in a minimised window. Zero frames, zero CPU.
2. *Each string is pre-rendered once into an opaque pixmap* with the
background already composited in, and blitted at its current offset
every frame. Drawing 5400 glyphs with ``drawText`` costs 35 ms a
frame; the same glyphs as 120 opaque strips cost 0.46 ms — and an
opaque strip is 25x cheaper to blit than a translucent one, which
is why the alpha is baked in rather than applied by the painter.
A strip is re-rendered only when its column respawns or the
styling changes; the cache is ~7 MB at 1920x1080.
:meth:`DnaRainWidget.set_backdrop` gives that up deliberately — a
picture under the rain is not a constant to bake against — so the
translucent path is taken only by the themes that have a wallpaper
to show, and dark and light keep the numbers above.
3. *Only the columns that moved are repainted.* Positions are
quantised to whole cells, so a column is dirty only when its
integer row changes — slow columns cost nothing on most ticks. See
:data:`MAX_DIRTY_RECTS` for where partial repaints stop paying.
Together: 0.53 ms a frame, 3.2 % of one core at 1920x1080 and 60 fps,
and 0.00 % while off screen. Random colour does not move that number —
the hues are baked into the same cached strips, and a pen set is built
once per whole degree of hue (:func:`_hue_bucket`) rather than per
column, per frame.
"""
from __future__ import annotations
import math
import random
import weakref
from dataclasses import dataclass
from typing import List, Optional, Tuple, Union
from PySide6.QtCore import (QElapsedTimer, QEvent, QPoint, QRect, Qt, QTimer,
Signal)
from PySide6.QtGui import (QColor, QFont, QFontDatabase, QFontMetricsF,
QImage, QPainter, QPen, QPixmap)
from PySide6.QtWidgets import (QGridLayout,
QHBoxLayout, QLabel, QPushButton, QSizePolicy,
QSlider, QWidget)
from ..theme import RADIUS, SPACING, palette_for
from .colour_picker import pick_colour
from .toggle import Toggle
#: The whole alphabet. ATGC, uppercase, nothing else.
BASES = ("A", "T", "G", "C")
#: The shipped glyph colour. Teal reads as DNA-ish without competing with
#: spaCR's own accent, and stays legible on both flat themes.
#:
#: This is the *default*, not a theme lookup. The rain used to take the
#: theme's ``accent``, which is the same blue as the Run button and the AI
#: toggle — so the backdrop read as chrome. :meth:`DnaRainWidget.apply_theme`
#: still exists for anyone who explicitly wants the palette's colour.
DEFAULT_COLOR = "#009B9B"
#: With random colour on, every column gets its own hue. Saturation and
#: lightness are borrowed from the picked colour so the field stays one
#: family, but they are floored/clamped: a near-grey pick would otherwise
#: make "random" produce twelve indistinguishable greys, and a near-white
#: one would wash every hue out.
RANDOM_MIN_SATURATION = 0.55
RANDOM_MIN_LIGHTNESS = 0.30
RANDOM_MAX_LIGHTNESS = 0.72
#: The one non-base token. Exact casing is load-bearing.
SPACR_TOKEN = "spaCR"
#: Probability that a freshly (re)spawned column carries a ``spaCR``.
#:
#: "Very infrequently" needs a number to be testable, so here is the
#: arithmetic. At 1920x1080 with the default 16 px font there are 120
#: columns and 67 rows. A column lives for ``(length + rows) / speed``
#: seconds — mean length 55, mean speed 13 cells/s — which measures out
#: at 10.2 respawns per second across the whole field. At this
#: probability that is **one ``spaCR`` every ~61 seconds** of viewing:
#: about once a minute, often enough to be noticed and rare enough to
#: stay a surprise. Halve the font size and you double the column count
#: and roughly halve the interval.
SPACR_SPLICE_PROBABILITY = 0.0016
#: String length range in cells, inherited from ``generate_dna_matrix``.
MIN_STRING_CELLS = 10
MAX_STRING_CELLS = 100
#: A string is additionally capped at this multiple of the canvas
#: height. Only bites at large font sizes, where a 100-cell string
#: would be nine screens tall — it would read as a solid bar, and its
#: pre-rendered strip would be megabytes.
MAX_STRING_SCREENS = 1.5
#: Per-column base fall speed, in cells per second. The spread is the
#: whole point: it is what stops the columns marching in lockstep.
MIN_SPEED_CELLS_PER_S = 4.0
MAX_SPEED_CELLS_PER_S = 22.0
#: Length of the highlighted run inside a string.
HIGHLIGHT_RUN_CELLS = 8
#: Fraction of a string (from the trailing end) over which it fades out,
#: and the alpha multiplier the very last glyph fades to.
TAIL_FADE_FRACTION = 0.35
MIN_TAIL_ALPHA = 0.12
#: Number of precomputed trail pens. Building a QColor per glyph per
#: frame is the single easiest way to burn a core here; this is the LUT
#: that avoids it.
TRAIL_STEPS = 12
DEFAULT_FONT_PX = 16
MIN_FONT_PX = 4
MAX_FONT_PX = 96
#: Frame-rate cap. 24 fps was enough for the fast columns and visibly stepped on the slow
#: ones: a column at 4 cells/s advances one whole glyph every 6 frames, so it
#: sat still and then jumped. Positions are quantised to whole cells, so the
#: only way to smooth that is to give the slow columns more frames to move in.
#: 60 costs more, but the dirty-rect repaint means an idle column still costs
#: nothing — only the columns that actually crossed a cell boundary repaint.
DEFAULT_FPS = 60
MIN_FPS = 1
MAX_FPS = 60
#: Glyph opacity. The rain sits BEHIND the screen content, so this trades
#: visibility against the legibility of whatever is in front of it. It began
#: at 0.22, which the user found too faint to read as an effect at all --
#: especially over a light theme, where a low-alpha accent colour on a pale
#: surface has almost no contrast to spend. Raised, and exposed as a slider so
#: it can be dialled per taste and per theme rather than guessed at once here.
#: Glyph opacity. 20% keeps the rain a texture behind the screen content
#: rather than something competing with it.
DEFAULT_OPACITY = 0.20
#: Slider bounds for the opacity control, as whole percent.
MIN_OPACITY_PCT = 5
MAX_OPACITY_PCT = 90
#: The head glyph is drawn at this multiple of the trail opacity
#: (clamped to 1.0) so it stays the brightest thing in the column.
HEAD_ALPHA_BOOST = 3.0
#: Largest simulation step accepted from the wall clock. If the app was
#: busy for two seconds the rain resumes where it was rather than
#: teleporting every column down the screen.
MAX_DT = 0.25
#: Dirty spans of adjacent columns are merged, and above this many
#: resulting rectangles the widget asks for one full repaint instead.
#:
#: Measured, not guessed. Because the pre-rendered strips make the
#: painting itself nearly free, what dominates a partial repaint is the
#: per-rectangle bookkeeping — invalidate, clip, flush — at roughly
#: 0.2 ms each. End-to-end median frame cost at 1920x1080:
#:
#: rects/frame partial one full repaint
#: 1.2 0.25 ms 0.51 ms
#: 3.2 0.63 ms 0.51 ms
#: 7.8 1.68 ms 0.71 ms
#: 29.0 5.98 ms 0.80 ms
#:
#: so partial repaints win up to about two rectangles and lose badly
#: above that.
MAX_DIRTY_RECTS = 2
#: Speed-multiplier range offered by the settings bar.
MIN_SPEED_MULTIPLIER = 0.2
MAX_SPEED_MULTIPLIER = 4.0
#: How far, in HSL lightness, the head glyph is pushed away from the
#: trail colour, and the minimum separation it must end up with from
#: both the trail and the background before we give up and go to an
#: extreme.
HEAD_LIGHT_STEP = 0.45
HEAD_MIN_SEPARATION = 0.20
[docs]
def derive_head_color(base: QColor, background: QColor) -> QColor:
"""Return the leading-glyph colour for a trail colour of ``base``.
The user picks one colour; the head is *derived* from it rather than
being a second colour they cannot control. It is pushed away in HSL
lightness, in whichever direction gains the most separation from
both the trail and the background — so it survives the extremes,
including a trail colour identical to the background.
:param base: the user's trail colour.
:param background: what the rain is painted onto.
:returns: a colour distinguishable from both, hue/saturation kept.
"""
bl = base.lightnessF()
gl = background.lightnessF()
def gain(light: float) -> float:
"""How far a lightness is from both reference lightnesses."""
return min(abs(light - bl), abs(light - gl))
up = min(1.0, bl + HEAD_LIGHT_STEP)
down = max(0.0, bl - HEAD_LIGHT_STEP)
target = up if gain(up) >= gain(down) else down
if gain(target) < HEAD_MIN_SEPARATION:
target = max((0.0, 1.0), key=gain)
hue = base.hueF()
sat = base.saturationF()
if hue < 0.0:
hue, sat = 0.0, 0.0
return QColor.fromHslF(hue, sat, target)
[docs]
def random_hue_color(base: QColor, hue: float) -> QColor:
"""Return ``base`` moved to ``hue``, keeping the family it belongs to.
Random colour is *per column*, and a column is one falling string
among a hundred. Rolling all three HSL components would have given
the field a scatter of near-blacks, near-whites and greys — most of
which do not read as glyphs at 20 % opacity behind a settings form.
Only the hue is random; saturation and lightness are taken from the
colour in the picker, floored and clamped so the result is always a
colour and always visible.
:param base: the picked colour, which lends its saturation/lightness.
:param hue: hue in ``0..1``.
:returns: a fully saturated-enough, mid-lightness colour at ``hue``.
"""
sat = max(RANDOM_MIN_SATURATION, base.saturationF())
light = min(RANDOM_MAX_LIGHTNESS,
max(RANDOM_MIN_LIGHTNESS, base.lightnessF()))
return QColor.fromHslF(max(0.0, min(0.9999, float(hue))), sat, light)
[docs]
def blend(a: QColor, b: QColor, t: float) -> QColor:
"""Linear RGB blend, ``t=0`` -> ``a``, ``t=1`` -> ``b``.
:param a: colour returned at ``t=0``.
:param b: colour returned at ``t=1``.
:param t: blend fraction, clamped to 0..1; only the red, green and blue
channels are blended.
"""
t = max(0.0, min(1.0, float(t)))
return QColor(
int(round(a.red() + (b.red() - a.red()) * t)),
int(round(a.green() + (b.green() - a.green()) * t)),
int(round(a.blue() + (b.blue() - a.blue()) * t)),
)
def _as_color(value: Union[QColor, str, None], fallback: QColor) -> QColor:
"""Coerce ``value`` to a valid QColor, falling back when it is not."""
if value is None:
return QColor(fallback)
color = QColor(value)
return color if color.isValid() else QColor(fallback)
def _as_pixmap(value) -> Optional[QPixmap]:
"""Coerce a path / QPixmap / QImage to a usable QPixmap, or ``None``.
Never raises and never returns a null pixmap: a wallpaper that has
been deleted between the stylesheet being built and this widget
being constructed is a cosmetic miss, not a crash, and the rain
falls back to its flat background colour.
"""
if value is None:
return None
try:
if isinstance(value, QPixmap):
pixmap = value
elif isinstance(value, QImage):
pixmap = QPixmap.fromImage(value)
else:
pixmap = QPixmap(str(value))
except Exception:
return None
return None if pixmap.isNull() else pixmap
def _region_rects(region, fallback: QRect) -> List[QRect]:
"""The individual rectangles of a paint region.
Qt hands ``paintEvent`` a *region* but reports ``event.rect()`` as
its bounding box; using the box would repaint the whole canvas as
soon as one column on each edge moved. Older/other Qt bindings do
not expose the rectangles, so fall back to the bounding box.
"""
try:
rects = [QRect(r) for r in region]
except Exception:
rects = []
return rects or [fallback]
@dataclass
[docs]
class Column:
"""One falling string.
:ivar tokens: one entry per *cell*; usually a single base, but a
spliced entry is the whole ``spaCR`` word.
:ivar length: number of cells (== ``len(tokens)``).
:ivar speed: base fall rate in cells/second, before the multiplier.
:ivar head: row of the leading glyph, fractional and often negative
(the string starts above the canvas).
:ivar row: ``floor(head)`` — the cell the head occupies. Still used for
the dirty-span arithmetic, which works in rows.
:ivar y_px: the strip's top edge in PIXELS, rounded from the fractional
head. THIS is what the column is painted at and what decides whether
it is dirty. Painting at ``row * cell`` quantised every column to
whole glyph heights, so a slow column (4 cells/s) sat still for six
frames and then jumped a whole character — the stepping that reads as
choppy. Raising the frame rate cannot fix that on its own: the
position has fewer allowed values.
:ivar hi_start: first cell index of the highlighted run.
:ivar hi_end: one past the last cell index of the highlighted run.
:ivar word_index: cell index of the multi-character token, or -1.
:ivar generation: bumped on every respawn; the widget's pre-rendered
strip cache keys off it.
:ivar hue: this string's own hue in ``0..1``, re-rolled on every
respawn. Only read when the widget is in random-colour mode; it
is rolled unconditionally, and from a stream of its own, so that
turning random colour on or off cannot change *where* anything
falls (see :class:`DnaRainEngine`).
"""
tokens: List[str]
length: int
speed: float
head: float
row: int
y_px: int
hi_start: int
hi_end: int
word_index: int
generation: int = 0
hue: float = 0.0
@property
[docs]
def has_word(self) -> bool:
"""True when this string carries a multi-character token."""
return self.word_index >= 0
[docs]
class DnaRainEngine:
"""Qt-free simulation of the falling columns.
Deterministic: the same ``seed`` and the same sequence of calls
always produce the same animation.
Per-column hues come from a **second** RNG rather than the main one.
Drawing them from the main stream would have shifted every length,
speed and start row by one draw, so a seeded rain would have fallen
differently depending on a purely cosmetic setting. Two streams keep
``snapshot()`` byte-identical whether random colour is on or off.
:param width: canvas width in pixels.
:param height: canvas height in pixels.
:param font_size: glyph size in pixels; also the cell/column stride.
:param seed: RNG seed. ``None`` seeds from the system entropy.
:param spacr_probability: chance per respawn of a ``spaCR`` splice.
"""
def __init__(self, width: int = 0, height: int = 0,
font_size: int = DEFAULT_FONT_PX,
seed: Optional[int] = None,
spacr_probability: float = SPACR_SPLICE_PROBABILITY):
"""Roll the falling columns from the seed.
:param width: the field's width in pixels.
:param height: its height.
:param font_size: the cell size the columns are pitched on.
:param seed: what makes the animation reproducible.
:param spacr_probability: how often a column splices in the word.
"""
self._rng = random.Random(seed)
self._hue_rng = random.Random(
None if seed is None else (int(seed) ^ 0x9E3779B9))
self.seed = seed
self.spacr_probability = float(spacr_probability)
self.speed_multiplier = 1.0
self.spacr_splices = 0
self.respawns = 0
self.frames = 0
self._font_size = _clamp_int(font_size, MIN_FONT_PX, MAX_FONT_PX)
self._width = max(0, int(width))
self._height = max(0, int(height))
self.columns: List[Column] = []
self.relayout()
@property
[docs]
def cell_size(self) -> int:
"""Row height and column stride, in pixels."""
return self._font_size
@property
[docs]
def font_size(self) -> int:
"""The cell size the columns are laid out on, in pixels.
:returns: the font size.
"""
return self._font_size
@property
[docs]
def n_rows(self) -> int:
"""Number of whole cells that fit vertically. May be 0."""
return self._height // self._font_size
@property
[docs]
def n_columns(self) -> int:
"""How many falling strings the simulation holds.
:returns: the column count.
"""
return len(self.columns)
@property
[docs]
def max_length(self) -> int:
"""Longest string this canvas allows — see MAX_STRING_SCREENS."""
cap = int(self.n_rows * MAX_STRING_SCREENS)
return max(MIN_STRING_CELLS, min(MAX_STRING_CELLS, cap))
@property
[docs]
def size(self) -> Tuple[int, int]:
"""Canvas size in pixels."""
return (self._width, self._height)
[docs]
def resize(self, width: int, height: int) -> bool:
"""Resize the canvas and re-lay-out the columns.
:param width: new canvas width in pixels; negative values become 0.
:param height: new canvas height in pixels; negative values become 0.
:returns: True when the size actually changed.
"""
width, height = max(0, int(width)), max(0, int(height))
if (width, height) == (self._width, self._height):
return False
self._width, self._height = width, height
self.relayout()
return True
[docs]
def set_font_size(self, px: int) -> None:
"""Set the glyph size, which is also the column stride.
:param px: glyph size in pixels, clamped to :data:`MIN_FONT_PX` ..
:data:`MAX_FONT_PX`; a change re-lays-out the columns.
"""
px = _clamp_int(px, MIN_FONT_PX, MAX_FONT_PX)
if px == self._font_size:
return
self._font_size = px
self.relayout()
[docs]
def set_speed_multiplier(self, factor: float) -> None:
"""Scale every column's speed, preserving their relative rates.
:param factor: multiplier applied to every column's speed; negative
values become 0.
"""
self.speed_multiplier = max(0.0, float(factor))
[docs]
def relayout(self) -> None:
"""Rebuild the column list for the current size and font."""
wanted = self._width // self._font_size if self._font_size else 0
wanted = max(0, wanted)
self.columns = [self._spawn(initial=True) for _ in range(wanted)]
def _new_tokens(self, length: int) -> Tuple[List[str], int]:
"""Roll a string of bases, occasionally splicing in ``spaCR``.
:returns: ``(tokens, word index)``; the index is -1 when no splice
happened, otherwise the cell index of the word's FIRST letter.
The word occupies ``len(SPACR_TOKEN)`` consecutive cells, one letter
each, so it falls down the column exactly like the bases around it::
s
p
a
C
R
It used to be written into a single cell as the whole string and drawn
horizontally, which made it read as a label pasted across the rain
rather than as part of it — and forced a second paint pass that
cleared a rectangle running over its neighbouring columns.
A string shorter than the word is left unspliced rather than truncated:
half a word is not the surprise this is for.
"""
rng = self._rng
tokens = [rng.choice(BASES) for _ in range(length)]
word_index = -1
word_len = len(SPACR_TOKEN)
if length >= word_len and rng.random() < self.spacr_probability:
word_index = rng.randrange(length - word_len + 1)
tokens[word_index:word_index + word_len] = list(SPACR_TOKEN)
self.spacr_splices += 1
return tokens, word_index
def _roll(self, column: Optional[Column], initial: bool) -> Column:
"""Fill one column with fresh tokens.
:param column: the column to fill.
:param initial: True on the first roll, when columns start mid-fall.
"""
rng = self._rng
length = rng.randint(MIN_STRING_CELLS, self.max_length)
speed = rng.uniform(MIN_SPEED_CELLS_PER_S, MAX_SPEED_CELLS_PER_S)
tokens, word_index = self._new_tokens(length)
if initial:
head = rng.uniform(-(length + self.n_rows), float(self.n_rows))
else:
head = -1.0
run = min(HIGHLIGHT_RUN_CELLS, length)
hi_start = rng.randrange(0, length - run + 1)
hi_end = hi_start + run
hue = self._hue_rng.random()
if column is None:
return Column(tokens=tokens, length=length, speed=speed,
head=head, row=int(math.floor(head)),
y_px=0,
hi_start=hi_start, hi_end=hi_end,
word_index=word_index, hue=hue)
column.hue = hue
column.tokens = tokens
column.length = length
column.speed = speed
column.head = head
column.row = int(math.floor(head))
column.y_px = self._y_px(column)
column.hi_start = hi_start
column.hi_end = hi_end
column.word_index = word_index
column.generation += 1
return column
def _spawn(self, initial: bool) -> Column:
"""Create every column.
:param initial: True on the first spawn, so the field starts full
rather than raining in from the top edge.
"""
return self._roll(None, initial)
def _respawn(self, column: Column) -> None:
"""Send one finished column back to the top with new tokens.
:param column: the column that fell off the bottom.
"""
self._roll(column, initial=False)
self.respawns += 1
def _y_px(self, column: "Column") -> int:
"""Top edge of ``column``'s strip in pixels, from its FRACTIONAL head.
Rounding here rather than flooring to a cell is the whole of the
smoothing: the strip may now sit at any pixel, so a slow column
creeps a pixel at a time instead of holding still and jumping a full
glyph. It is still an integer, so a column that has not moved a whole
pixel yet is skipped and costs nothing.
"""
return int(round((column.head - column.length + 1) * self.cell_size))
[docs]
def advance(self, dt: float) -> List[Tuple[int, int, int]]:
"""Step the simulation by ``dt`` seconds.
:param dt: elapsed time in seconds.
:returns: ``(column index, first row, last row)`` spans that
changed, already clipped to the canvas. Columns whose
integer row did not move contribute nothing — that is the
whole point of quantising to cells.
"""
self.frames += 1
rows = self.n_rows
if not self.columns or rows <= 0 or dt <= 0.0:
return []
dirty: List[Tuple[int, int, int]] = []
for index, column in enumerate(self.columns):
old_row = column.row
old_top = old_row - column.length + 1
column.head += column.speed * self.speed_multiplier * dt
respawned = False
if column.head - column.length + 1 > rows:
self._respawn(column)
respawned = True
new_row = int(math.floor(column.head))
old_y = column.y_px
new_y = self._y_px(column)
if new_y == old_y and not respawned:
continue
column.row = new_row
column.y_px = new_y
cell = max(1, self.cell_size)
top = max(0, min(old_y, new_y) // cell - 1)
bottom = min(rows - 1,
(max(old_y, new_y) + column.length * cell) // cell + 1)
if bottom >= top:
dirty.append((index, top, bottom))
return dirty
[docs]
def column_text(self, index: int) -> str:
"""The full string of column ``index``, tokens concatenated.
:param index: position of the column in :attr:`columns`.
"""
return "".join(self.columns[index].tokens)
[docs]
def tokens(self) -> List[str]:
"""Every token currently in the field, flattened."""
return [tok for column in self.columns for tok in column.tokens]
[docs]
def snapshot(self) -> Tuple:
"""A hashable state summary — used to compare two runs."""
return tuple(
(round(c.head, 6), round(c.speed, 6), c.length,
c.hi_start, tuple(c.tokens))
for c in self.columns
)
def _clamp_int(value, low: int, high: int) -> int:
"""Clamp a value into an integer range.
:param value: the value.
:param low: the lower bound.
:param high: the upper bound.
:returns: the clamped integer.
"""
return max(low, min(high, int(value)))
def _hue_bucket(hue: float) -> int:
"""``hue`` in ``0..1`` as a whole degree — the pen cache's key.
360 buckets is finer than anyone can see at 20 % alpha and bounds
the cache at 360 entries however long the rain runs.
"""
return int(float(hue) * 360) % 360
def _effective_theme() -> str:
"""The user's resolved theme, or dark when preferences are unavailable."""
try:
from ..preferences import resolve_effective_theme
return resolve_effective_theme()
except Exception:
return "dark"
[docs]
class DnaRainSettingsBar(QWidget):
"""Colour / speed / visibility / font-size controls for a rain widget.
Everything applies live — :meth:`bind` wires the signals straight at
the rain widget's setters, no restart involved.
Two layouts, one set of controls. ``vertical=True`` puts them in a
label/control/readout grid, which is the shape a popover wants; the
default row is the original bar. The controls, the state and the
signals are identical either way — only the geometry differs.
:param vertical: lay the controls out as a grid instead of a row.
"""
color_changed = Signal(QColor)
speed_changed = Signal(float)
font_size_changed = Signal(int)
opacity_changed = Signal(float)
random_color_changed = Signal(bool)
def __init__(self, parent: Optional[QWidget] = None, *,
color: Union[QColor, str, None] = None,
speed: float = 1.0,
font_size: int = DEFAULT_FONT_PX,
opacity: float = DEFAULT_OPACITY,
random_color: bool = False,
vertical: bool = False,
theme: Optional[str] = None):
"""Build the controls for the DNA-rain backdrop.
Every parameter is the STARTING value of a control, not a fixed
setting: the bar exists to change them, and each has a widget below.
:param parent: parent widget.
:param color: the glyph colour to start on; anything
:func:`_as_color` accepts, defaulting to ``DEFAULT_COLOR``.
:param speed: fall speed multiplier.
:param font_size: glyph size in pixels.
:param opacity: glyph opacity, 0 to 1.
:param random_color: start with a colour per column rather than one
colour, which makes the swatch inactive until it is turned off.
:param vertical: lay the bar out as a column instead of a row.
:param theme: which theme to style against, or ``None`` for the
active one. The bar needs an opaque surface of its own because
the rain is painted behind it and the global background rule
does not reach a widget carrying its own stylesheet.
"""
super().__init__(parent)
self._color = _as_color(color, QColor(DEFAULT_COLOR))
self._rain: Optional[DnaRainWidget] = None
self.setObjectName("DnaRainBar")
self.setAttribute(Qt.WA_StyledBackground, True)
self.restyle_for_theme(theme)
self._swatch = QPushButton()
self._swatch.setObjectName("DnaRainSwatch")
self._swatch.setFixedSize(44, 20)
self._swatch.setToolTip("Pick the DNA rain colour")
self._swatch.clicked.connect(self.pick_color)
self._paint_swatch()
self._random = Toggle("Random")
self._random.setToolTip(
"Give every falling string its own colour. The picked colour "
"still sets how vivid and how bright they are; only the hue is "
"random, and each string takes a new one every time it restarts "
"at the top.")
self._random.setChecked(bool(random_color))
self._random.toggled.connect(self._on_random)
self._speed = QSlider(Qt.Horizontal)
self._speed.setToolTip("Scales every column; they keep their "
"individual rates")
self._speed.setRange(int(MIN_SPEED_MULTIPLIER * 100),
int(MAX_SPEED_MULTIPLIER * 100))
self._speed.setValue(int(max(MIN_SPEED_MULTIPLIER,
min(MAX_SPEED_MULTIPLIER,
float(speed))) * 100))
self._speed.setFixedWidth(120)
self._speed.valueChanged.connect(self._on_speed)
self._speed_value = _muted_label("")
self._opacity = QSlider(Qt.Horizontal)
self._opacity.setToolTip(
"How strongly the rain shows through behind the screen content. "
"Higher is more visible; too high and the settings in front of it "
"get harder to read.")
self._opacity.setRange(MIN_OPACITY_PCT, MAX_OPACITY_PCT)
self._opacity.setValue(_clamp_int(round(opacity * 100),
MIN_OPACITY_PCT, MAX_OPACITY_PCT))
self._opacity.setFixedWidth(120)
self._opacity.valueChanged.connect(self._on_opacity)
self._opacity_value = _muted_label("")
self._font = QSlider(Qt.Horizontal)
self._font.setToolTip("Glyph size, which is also the column stride")
self._font.setRange(MIN_FONT_PX, MAX_FONT_PX)
self._font.setValue(_clamp_int(font_size, MIN_FONT_PX, MAX_FONT_PX))
self._font.setFixedWidth(120)
self._font.valueChanged.connect(self._on_font)
self._font_value = _muted_label("")
if vertical:
self._build_grid()
else:
self._build_row()
self._refresh_readouts()
[docs]
def restyle_for_theme(self, theme: Optional[str] = None) -> None:
"""Re-take this panel's own surface colour from a theme's palette.
The bar states its own background, so unlike the rest of the
screen it is NOT re-styled by re-applying the application
stylesheet — it would keep the dark theme's surface behind
freshly light text. The popover calls this every time it opens,
which is the only moment the panel is on screen.
Only the chrome. The user's chosen colour is never touched.
:param theme: palette to take; defaults to the effective theme.
"""
palette = palette_for(theme or _effective_theme())
self.setStyleSheet(
f"QWidget#DnaRainBar {{ background: {palette['surface']};"
f" border: 1px solid {palette['border_soft']};"
f" border-radius: {RADIUS['sm']}px; }}")
def _build_row(self) -> None:
"""The original one-line bar."""
layout = QHBoxLayout(self)
layout.setContentsMargins(SPACING["md"], SPACING["sm"],
SPACING["md"], SPACING["sm"])
layout.setSpacing(SPACING["md"])
layout.addWidget(_muted_label("DNA rain"))
layout.addWidget(_muted_label("Colour"))
layout.addWidget(self._swatch)
layout.addWidget(self._random)
layout.addWidget(_muted_label("Speed"))
layout.addWidget(self._speed)
layout.addWidget(self._speed_value)
layout.addWidget(_muted_label("Visibility"))
layout.addWidget(self._opacity)
layout.addWidget(self._opacity_value)
layout.addWidget(_muted_label("Font"))
layout.addWidget(self._font)
layout.addWidget(self._font_value)
layout.addStretch(1)
def _build_grid(self) -> None:
"""Label / control / readout, one setting per line."""
grid = QGridLayout(self)
grid.setContentsMargins(SPACING["md"], SPACING["md"],
SPACING["md"], SPACING["md"])
grid.setHorizontalSpacing(SPACING["md"])
grid.setVerticalSpacing(SPACING["sm"])
title = _muted_label("DNA rain")
grid.addWidget(title, 0, 0, 1, 3)
grid.addWidget(_muted_label("Colour"), 1, 0)
colour_row = QHBoxLayout()
colour_row.setContentsMargins(0, 0, 0, 0)
colour_row.setSpacing(SPACING["sm"])
colour_row.addWidget(self._swatch)
colour_row.addStretch(1)
grid.addLayout(colour_row, 1, 1)
grid.addWidget(self._random, 1, 2)
for row, (name, control, readout) in enumerate((
("Speed", self._speed, self._speed_value),
("Visibility", self._opacity, self._opacity_value),
("Font size", self._font, self._font_value)), start=2):
grid.addWidget(_muted_label(name), row, 0)
grid.addWidget(control, row, 1)
grid.addWidget(readout, row, 2)
[docs]
def color(self) -> QColor:
"""The colour currently chosen in the bar.
A COPY, so the caller cannot edit the bar's own colour in place.
:returns: the chosen colour.
"""
return QColor(self._color)
[docs]
def speed(self) -> float:
"""The chosen speed multiplier.
The slider counts in percent because a QSlider is integer-valued;
this is the number the rain actually wants.
:returns: the multiplier.
"""
return self._speed.value() / 100.0
[docs]
def font_size(self) -> int:
"""The chosen cell size, in pixels.
:returns: the font size.
"""
return self._font.value()
[docs]
def opacity(self) -> float:
"""The chosen opacity, 0 to 1.
:returns: the opacity.
"""
return self._opacity.value() / 100.0
[docs]
def random_color(self) -> bool:
"""True when the rain is set to colour each column separately."""
return self._random.isChecked()
def _paint_swatch(self) -> None:
"""Redraw the colour swatch from the chosen colour."""
self._swatch.setStyleSheet(
"QPushButton#DnaRainSwatch {"
f" background: {self._color.name()};"
" border: 1px solid rgba(255,255,255,0.35);"
" border-radius: 3px; }")
def _refresh_readouts(self) -> None:
"""Update the numbers beside each slider."""
self._speed_value.setText(f"{self.speed():.1f}x")
self._font_value.setText(f"{self.font_size()} px")
self._opacity_value.setText(f"{round(self.opacity() * 100)}%")
[docs]
def set_color(self, color: Union[QColor, str]) -> None:
"""Set the colour and emit :attr:`color_changed`.
:param color: a :class:`QColor` or colour name; an invalid colour keeps
the current one.
"""
self._color = _as_color(color, self._color)
self._paint_swatch()
self.color_changed.emit(QColor(self._color))
[docs]
def pick_color(self) -> None:
"""Open the colour picker; keep the current colour on cancel."""
chosen = pick_colour(self, self._color, "DNA rain colour")
if chosen.isValid():
self.set_color(chosen)
[docs]
def set_speed(self, factor: float) -> None:
"""Move the speed slider to ``factor``.
:param factor: the multiplier; converted to the slider's percent.
"""
self._speed.setValue(int(round(float(factor) * 100)))
[docs]
def set_font_size(self, px: int) -> None:
"""Move the font slider, clamped to the range the bar offers.
:param px: the wanted size in pixels.
"""
self._font.setValue(_clamp_int(px, MIN_FONT_PX, MAX_FONT_PX))
[docs]
def set_random_color(self, on: bool) -> None:
"""Turn per-column colour on or off; emits only on a real change.
:param on: whether the per-column colour check box is ticked.
"""
self._random.setChecked(bool(on))
def _on_random(self, on: bool) -> None:
"""Turn per-column random colours on or off.
:param on: True for random colours.
"""
self.random_color_changed.emit(bool(on))
def _on_speed(self, _value: int) -> None:
"""Apply the speed slider.
:param _value: the slider's position; re-read from the widget.
"""
self._refresh_readouts()
self.speed_changed.emit(self.speed())
[docs]
def set_opacity(self, value: float) -> None:
"""Move the opacity slider, clamped to the range the bar offers.
CLAMPED RATHER THAN REFUSED: this is restored from a saved
preference, and a value from an older build with a wider range
should land at the nearest legal one rather than stop the restore.
:param value: the wanted opacity, 0 to 1.
"""
self._opacity.setValue(
_clamp_int(round(float(value) * 100), MIN_OPACITY_PCT,
MAX_OPACITY_PCT))
def _on_font(self, value: int) -> None:
"""Apply the font-size slider.
:param value: the new size in pixels.
"""
self._refresh_readouts()
self.font_size_changed.emit(int(value))
def _on_opacity(self, _value: int) -> None:
"""Apply the opacity slider.
:param _value: the slider's position; re-read from the widget.
"""
self._refresh_readouts()
self.opacity_changed.emit(self.opacity())
[docs]
def bind(self, rain: DnaRainWidget) -> None:
"""Drive ``rain`` from this bar, and seed the bar from the rain.
:param rain: the rain widget to drive; the bar's controls are seeded
from its current colour, speed, font size, opacity and colour mode,
and the bar's signals are connected to its setters.
"""
self._rain = rain
self._color = rain.color()
self._paint_swatch()
self._speed.blockSignals(True)
self._speed.setValue(int(round(rain.speed() * 100)))
self._speed.blockSignals(False)
self._font.blockSignals(True)
self._font.setValue(rain.font_size())
self._font.blockSignals(False)
self._opacity.blockSignals(True)
self._opacity.setValue(
_clamp_int(round(rain.opacity() * 100), MIN_OPACITY_PCT,
MAX_OPACITY_PCT))
self._opacity.blockSignals(False)
self._random.blockSignals(True)
self._random.setChecked(rain.random_colors())
self._random.blockSignals(False)
self._refresh_readouts()
self.color_changed.connect(rain.set_color)
self.speed_changed.connect(rain.set_speed)
self.font_size_changed.connect(rain.set_font_size)
self.opacity_changed.connect(rain.set_opacity)
self.random_color_changed.connect(rain.set_random_colors)
def _muted_label(text: str) -> QLabel:
"""Build a label in the muted style.
:param text: the text.
:returns: the label.
"""
label = QLabel(text)
label.setObjectName("Muted")
return label
[docs]
def install_dna_rain(host: QWidget, layout=None, *,
anchor: Optional[QWidget] = None,
**kwargs) -> DnaRainWidget:
"""Put a live DNA rain behind ``host``, and a DNA button in the chrome.
The rain becomes a child of ``host``, tracks its geometry, and is
lowered to the bottom of the sibling stacking order so every screen
widget paints in front of it. It takes no focus and no mouse events.
The controls are **not** placed on the screen. They live in a
popover behind a ``DNA`` toggle built from the same class as the
``AI`` toggle beside it; a decorative backdrop does not get to keep
a permanent strip of a screen whose job is a settings form.
Where the button lands, in order: beside ``anchor`` if one is
given; else beside the host's own ``AI`` toggle, which is the row
this control belongs in and the reason no caller has to say so;
else appended to ``layout``; else nowhere, and the caller places
``rain.settings_button`` itself.
:param host: the screen the rain sits behind.
:param layout: optional layout to append the DNA button to. Used
only when there is no anchor to sit beside.
:param anchor: widget to sit beside — the button is inserted into
``anchor``'s layout immediately before it. Defaults to the AI
toggle found under ``host``.
:param kwargs: forwarded to :class:`DnaRainWidget`. Pass
``backdrop=<wallpaper path>`` on an image theme so the rain
shows the picture through itself rather than replacing it.
:returns: the rain widget, with ``.settings_bar``,
``.settings_button`` and ``.settings_popover`` attached.
"""
rain = DnaRainWidget(host, **kwargs)
rain.follow_parent()
rain.show()
bar = DnaRainSettingsBar(None, theme=kwargs.get("theme"), vertical=True)
bar.bind(rain)
from .dna_rain_settings import DnaSettingsButton
button = DnaSettingsButton(bar, parent=host)
if not _place_beside(button, anchor or _find_ai_toggle(host)):
if layout is not None:
layout.addWidget(button)
rain.settings_bar = bar
rain.settings_button = button
rain.settings_popover = button.popover
return rain
def _find_ai_toggle(host: QWidget) -> Optional[QWidget]:
"""The host's ``AI`` toggle, or ``None`` if it has not got one.
Matched on the untranslated source text every ``AiToggleLabel``
keeps in ``_spacr_i18n_text``, not on what is on screen: the label
is translated, and matching the visible text would have put the
button in the right place in English and nowhere in Swedish. The
class is shared with the ``Live`` and hyperparameter switches, so
the text is what distinguishes them.
Never raises: a host with no such toggle simply gets the fallback
placement.
"""
try:
from .ai_toggle_label import AiToggleLabel
for label in host.findChildren(AiToggleLabel):
if label.property("_spacr_i18n_text") == "AI":
return label
except Exception:
pass
return None
def _place_beside(button: QWidget, anchor: Optional[QWidget]) -> bool:
"""Insert ``button`` immediately before ``anchor`` in its own layout.
Before, not after: the AI toggle is followed by its provider
chevron, and splitting that pair would read as the chevron belonging
to DNA.
:returns: True when the button was placed.
"""
if anchor is None:
return False
parent = anchor.parentWidget()
layout = parent.layout() if parent is not None else None
if layout is None:
return False
index = layout.indexOf(anchor)
if index < 0:
return False
try:
layout.insertWidget(index, button)
except AttributeError:
layout.addWidget(button)
return True