Source code for spacr.qt.widgets.setup_card

"""Render first-run questions in a rounded, pointer-responsive card.

The accent follows the corner nearest the pointer. All visual effects are
decorative: if a blur, background, or accent cannot be painted, the card
continues to display its questions and save their answers.
"""
from __future__ import annotations

import math
from typing import Optional

from PySide6.QtCore import QLineF, QPointF, QRectF, Qt, QTimer
from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QPainter,
                           QPainterPath, QPen, QPolygonF)
from PySide6.QtWidgets import QWidget

#: Corner order: top-left, top-right, bottom-right, bottom-left.
CORNERS = ("topLeft", "topRight", "bottomRight", "bottomLeft")

#: Seconds for the accent's colour to go once round the wheel under the
#: ``spaceout`` dressing.
#:
#: THIS IS THE MARK THAT FOLLOWS THE MOUSE. The lit run of rim is aimed by
#: `SetupCard._aim_at_the_cursor`, which reads the GLOBAL cursor position
#: rather than waiting for events -- which is why it goes on tracking a
#: pointer that has left the window, and why it is the "little blue window
#: thing that follows the mouse". Nothing else in the application paints a
#: mark that chases the pointer; `availability_panel` and `hover_tooltip`
#: read the cursor too, but to decide whether the pointer is inside a
#: region, not to draw anything at it.
#:
#: Six seconds is fast enough to be seen oscillating while the pointer is
#: still and slow enough not to strobe. It is deliberately much faster than
#: the palette's own drift, which takes nine minutes: the rim is a small
#: moving highlight and is the one place a quick colour cycle reads as alive
#: rather than as flicker.
SPACEOUT_RIM_PERIOD = 6.0

#: How much of the spectrum the run carries along its own length, in turns.
#:
#: A third, so the lit run is a piece of a rainbow rather than one colour
#: sliding through the whole of it -- the head and the tail are visibly
#: different colours at every moment, which is what makes the oscillation
#: read as a spectrum travelling rather than as the theme changing.
SPACEOUT_RIM_SPREAD = 0.34

#: The surface the arc preference is measured against, in pixels.
#:
#: The first-run window's own size. It is the card the look was tuned on,
#: so every other card lights the same fraction of its rim as this one
#: does -- see :meth:`SetupCard.accent_span`.
REFERENCE_CARD = (980.0, 700.0)


[docs] class SetupCard(QWidget): """A rounded, translucent card whose border lights at the near corner. :param radius: corner radius in pixels. :param arc: how far along each edge the accent runs, in pixels. """ #: Fallback chase fraction, when the preference cannot be read. #: #: THE PREFERENCE IS THE REAL ONE -- see `spacr.qt.preferences. #: get_rim_lag`. This is what the card uses when there is no settings #: store to ask, which is the case in a bare widget test. EASE = 0.5 #: Frames the tail is smeared over, as a fraction of the arc. #: #: THE ENDS FADE OUT rather than stopping. A run of rim at one alpha #: has two hard #: ends that arrive and leave abruptly; a run that fades has none, which #: is what makes it read as a highlight travelling rather than as a #: segment being switched on. FADE = 0.5 #: Rim pixels between two samples of the lit run. #: #: The run is ONE filled band shaded by one gradient, and the samples #: are where its outline bends and where the gradient takes a colour. #: Below about six pixels a chord is past what the eye resolves against #: an anti-aliased edge; four is comfortably under it. STEP_PX = 4.0 #: Never more samples than this, however large the card. #: #: A ceiling rather than a guess: at sixty frames a second the cost is #: paid every frame, and past this the extra samples are drawing detail #: finer than the anti-aliasing that is already smoothing them. MAX_STEPS = 320 def __init__(self, parent: Optional[QWidget] = None, *, radius: int = 18, arc: Optional[int] = None, lag: Optional[float] = None, align: str = "", mode: str = ""): """Build the card and the accent that travels round its rim. :param parent: parent widget. :param radius: the card's corner radius in pixels. :param arc: how long the travelling accent is. ``None`` takes the preference. :param lag: how hard the accent chases the pointer, 0 to 1. ``None`` takes the preference. :param align: whether the accent is centred on the pointer or trails behind it. ``""`` takes the preference. :param mode: which look the card wears; ``""`` for the default. THE THREE THAT ARE A MATTER OF TASTE -- ``arc``, ``lag`` and ``align`` -- default to the preference store rather than to constants, because how long the run is and how hard it chases are things to look at and decide about. A caller that wants a particular look for a particular card can still say so, which is why they are parameters at all and not read from preferences outright. """ super().__init__(parent) self._mode = str(mode or "").strip().lower() #: Radians of the animation cycle, advanced one frame at a time. #: #: COUNTED IN FRAMES, not read off a clock. The timer is the only #: thing driving this and it ticks at a known interval, so a frame #: count is a phase -- and unlike `time.time()` it stops when the #: card is hidden, which is what keeps a pulse from jumping when a #: dialog is reopened. self._phase = 0.0 self._radius = int(radius) self._arc = int(arc) if arc is not None else self._preferred_arc() self._relative_arc = arc is None self._lag = float(lag) if lag is not None else None self._align = str(align or "").strip().lower() self._corner = 0 #: Accent position as a clockwise fraction of the card perimeter, #: measured from the top-left corner. A continuous position lets the #: highlight move smoothly between corners. self._at = 0.0 #: Where it is heading. The gap between the two is what the easing #: closes, one tick at a time. self._towards = 0.0 #: Laps still to run, signed: + is clockwise, - anticlockwise. #: WHILE THIS IS NON-ZERO THE POINTER DOES NOT STEER. A lap dragged #: off course by a mouse movement is not a lap, and the user cannot #: tell whether it went round -- which is the whole signal. self._laps = 0.0 #: The preferences this frame is being drawn with, or None between #: frames. See :meth:`_held`. self._frame = None #: ``(key, path, length)`` for the rim last built. See :meth:`_rim`. self._rim_cache = None #: ``(key, span)`` for the lit fraction last worked out. self._span_cache = None self._timer = QTimer(self) self._timer.setInterval(16) self._timer.timeout.connect(self._tick) self.setMouseTracking(True) self.setAttribute(Qt.WA_Hover, True)
[docs] def nearest_corner(self, point: QPointF) -> int: """Index into :data:`CORNERS` of the corner nearest ``point``. Ties go to the earlier corner, which only happens at the exact centre and is therefore never seen. :param point: position in the card's own widget coordinates. """ rect = QRectF(self.rect()) points = (rect.topLeft(), rect.topRight(), rect.bottomRight(), rect.bottomLeft()) best, chosen = None, 0 for index, corner in enumerate(points): dx = point.x() - corner.x() dy = point.y() - corner.y() distance = dx * dx + dy * dy if best is None or distance < best: best, chosen = distance, index return chosen
[docs] def corner(self) -> str: """The corner the accent is currently on.""" return CORNERS[self._corner]
[docs] def mouseMoveEvent(self, event): # noqa: N802 - Qt naming """Track the pointer so the rim can chase it. :param event: the Qt mouse event. """ self._follow(event.position()) super().mouseMoveEvent(event)
[docs] def event(self, event): """Handle the events Qt gives no named handler for. :param event: the Qt event. :returns: True when handled here. """ try: from PySide6.QtCore import QEvent if event.type() == QEvent.Type.HoverMove: self._follow(event.position()) except Exception: pass return super().event(event)
def _follow(self, position) -> None: """Aim the accent at a point in this widget's own coordinates. IT USED TO SET ONLY `_corner`, which `_tick` recomputes from `_at` on the very next frame -- so while the pointer was over the CARD, which is nearly the whole dialog, nothing steered the rim at all. Only the dialog's margin, which has its own handler, ever moved it. That is the "unsynced on the inside" of the 2026-08-22 report. """ self.flow_towards(QPointF(position))
[docs] def paintEvent(self, event): # noqa: N802 - Qt naming """Draw the card and its animated rim. :param event: the Qt paint event. """ try: self._paint() except Exception: pass super().paintEvent(event)
[docs] def perimeter_position(self, point: QPointF): """Where on the rim ``point`` is, as a fraction clockwise from 0. THE RAY FROM THE CENTRE, not the nearest edge. Projecting a point onto whichever edge happens to be closest is discontinuous along the diagonals: the pointer crosses one and the target jumps from the middle of the top edge to the middle of the left, which is what was reported as the rim being unsynced with the pointer inside the card. The point where the ray from the centre through the pointer leaves the rectangle moves continuously, and is always the place on the rim that is actually in the pointer's direction -- which is what "flows towards the mouse" has to mean if the mouse can be anywhere. Because it is a RAY it needs no clamping, and answers for a pointer outside the card as readily as for one inside it. THE RIM IS TREATED AS A RECTANGLE, not as the rounded path it is drawn on. The corner arcs are a few pixels of a perimeter hundreds long, and paying for exact arc length here would buy an accuracy no eye can see on a value that is chasing a mouse anyway. :param point: position in the card's own widget coordinates; it may lie outside the card. :returns: the fraction, or None when the pointer is exactly at the centre and no direction can be read from it. """ rect = QRectF(self.rect()) width = max(1.0, rect.width()) height = max(1.0, rect.height()) half_w, half_h = width / 2.0, height / 2.0 dx = float(point.x()) - half_w dy = float(point.y()) - half_h if abs(dx) < 1e-9 and abs(dy) < 1e-9: return None span = max(abs(dx) / half_w, abs(dy) / half_h) x = half_w + dx / span y = half_h + dy / span x = min(max(x, 0.0), width) y = min(max(y, 0.0), height) total = 2.0 * (width + height) if y <= 1e-6: run = x elif x >= width - 1e-6: run = width + y elif y >= height - 1e-6: run = width + height + (width - x) else: run = 2.0 * width + height + (height - y) return (run / total) % 1.0
[docs] def flow_towards(self, point: QPointF) -> None: """Aim the accent at ``point``. Ignored while a circuit is running. A point at the exact centre names no direction and is ignored too, rather than being read as the top-left corner. :param point: position in the card's own widget coordinates; it may lie outside the card. """ if self._laps: return target = self.perimeter_position(point) if target is None: return self._towards = target self._start()
[docs] def circuit(self, clockwise: bool = True) -> None: """Send the accent once round: clockwise, or anti- for Previous. THE DIRECTION IS THE MESSAGE. It tells the user which way they went through the slides, which is worth more than the animation -- so a circuit is never merged with another or shortened to catch up. """ self._laps += 1.0 if clockwise else -1.0 self._start()
@property
[docs] def position(self) -> float: """The accent's place on the rim, 0..1 clockwise from the top-left.""" return self._at % 1.0
@property
[docs] def spinning(self) -> bool: """Whether a circuit is running.""" return bool(self._laps)
def _aim_at_the_cursor(self) -> bool: """Point the accent at wherever the cursor is. True if it moved. READ, NOT RECEIVED. A widget is sent mouse events only while the pointer is over it, and a modal dialog gets none at all once the pointer leaves the window -- so an accent that tracks "towards the mouse" cannot be driven by events and still follow a mouse that is somewhere else. The cursor position is global and always readable, which is the whole of the fix for "doesn't track the mouse on the outside". """ try: from PySide6.QtGui import QCursor here = self.mapFromGlobal(QCursor.pos()) except Exception: return False target = self.perimeter_position(QPointF(here)) if target is None: return False moved = abs(((target - self._towards + 0.5) % 1.0) - 0.5) > 1e-9 self._towards = target return moved def _held(self, name: str, read): """``read()``, or the answer already read for the frame being drawn. THE PREFERENCES ARE READ ONCE A FRAME, not once a segment. The lit run is drawn as hundreds of short strokes and each one asked for the mode, the period and the alignment -- several hundred openings of the settings store per frame, for values that cannot change while a single frame is being painted. They are read on the first ask and answered from the frame until it ends. NOTHING IS HELD BETWEEN FRAMES. Outside a paint this is a plain call through to ``read``, so a card still takes a slider's new value on the very next frame -- which is what :meth:`reread_the_preferences` relies on. """ frame = self._frame if frame is None: return read() if name not in frame: frame[name] = read() return frame[name] @staticmethod def _preferred_arc() -> int: """The stored rim length, or the shipped default.""" try: from ..preferences import get_rim_length return int(get_rim_length()) except Exception: # noqa: BLE001 return 280
[docs] def ease(self) -> float: """How far the accent closes the gap to the pointer each frame.""" if self._lag is not None: return float(self._lag) try: from ..preferences import get_rim_lag return float(get_rim_lag()) except Exception: # noqa: BLE001 return float(self.EASE)
[docs] def mode(self) -> str: """``'glow'``, ``'rainbow'`` or ``'beat'``.""" if self._mode: return self._mode return self._held("mode", self._stored_mode)
@staticmethod def _stored_mode() -> str: """Return the saved rim mode. :returns: the preference, or ``"beat"`` when it cannot be read -- a card with no rim would look broken, so the default is a real mode. """ try: from ..preferences import get_rim_mode return str(get_rim_mode()) except Exception: # noqa: BLE001 return "beat"
[docs] def period(self) -> float: """Seconds for one pulse, or one turn of the hue.""" return self._held("period", self._stored_period)
@staticmethod def _stored_period() -> float: """Return the saved rim period in seconds. :returns: the preference, or the shipped default when it cannot be read. """ try: from ..preferences import get_rim_period return float(get_rim_period()) except Exception: # noqa: BLE001 return 1.5 #: The two theme functions the dressing needs, resolved once. #: #: THE ANSWERS ARE NOT CACHED, only the LOOKUP. `ink_at` runs once per #: segment of the run and a long rim is three hundred of them a frame, #: so importing inside the loop is three hundred module lookups sixty #: times a second for a value that never moves. What must not be cached #: is what the functions RETURN: the dressing is process state and the #: drift changes every frame. _DRESSING = None @classmethod def _dressing(cls): """``(spaceout_enabled, spaceout_drift)``, or ``None``.""" if SetupCard._DRESSING is None: try: from ..theme import spaceout_drift, spaceout_enabled SetupCard._DRESSING = (spaceout_enabled, spaceout_drift) except Exception: # noqa: BLE001 return None return SetupCard._DRESSING
[docs] def spaceout(self) -> bool: """Return whether spaceout rendering is enabled for this process.""" def read(): """The current spaceout choice from the card's dressing.""" dressing = self._dressing() return bool(dressing[0]()) if dressing else False return self._held("spaceout", read)
[docs] def animates(self) -> bool: """Whether the rim changes when nothing else does. A pulse and a moving spectrum have to repaint on a still card; a plain glow must NOT, or an arrived rim costs sixty composites a second over a live backdrop for no visible change. Under the ``spaceout`` dressing every mode oscillates, so every mode paints. """ return self.mode() in ("rainbow", "beat") or self.spaceout()
[docs] def alignment(self) -> str: """``'centre'`` or ``'head'`` -- where the run sits on the pointer.""" if self._align: return self._align return self._held("alignment", self._stored_alignment)
@staticmethod def _stored_alignment() -> str: """Return the saved rim alignment. :returns: the preference, or ``"centre"`` when it cannot be read. """ try: from ..preferences import get_rim_alignment return str(get_rim_alignment()) except Exception: # noqa: BLE001 return "centre"
[docs] def reread_the_preferences(self) -> None: """Take the length, lag and alignment again, and redraw. Called when the settings that own them change, so the card the user is looking at while they drag a slider is the one that answers. """ self._arc = self._preferred_arc() self.update()
def _start(self) -> None: """Start the rim animation, if it is not already running.""" if not self._timer.isActive(): self._timer.start()
[docs] def showEvent(self, event): # noqa: N802 - Qt naming """Start following as soon as there is something to follow. :param event: the show event; it is not inspected, only passed on to the base class before following starts. """ super().showEvent(event) self._start()
[docs] def hideEvent(self, event): # noqa: N802 - Qt naming """A card nobody is looking at does not need sixty frames a second. :param event: the hide event; it is not inspected, only passed on to the base class before the animation timer stops. """ super().hideEvent(event) self._timer.stop()
def _tick(self) -> None: """One frame: run the laps down, else ease towards the pointer.""" self._phase += self._timer.interval() / 1000.0 if self._laps: step = 0.03 if self._laps > 0 else -0.03 self._at += step self._laps -= step if abs(self._laps) < 0.031: self._at = round(self._at + self._laps, 6) self._laps = 0.0 else: self._aim_at_the_cursor() gap = ((self._towards - self._at + 0.5) % 1.0) - 0.5 if abs(gap) < 0.002: if self._at == self._towards and not self.animates(): return self._at = self._towards else: self._at += gap * self.ease() self._corner = int((self.position + 0.125) % 1.0 * 4) % 4 self.update() def _paint(self) -> None: """Draw the card: its translucent body, then the animated rim. The body covers the whole card rather than the inset the rim is stroked on -- inset by a pixel it left a hairline of whatever sits behind along the straight edges, and half again as much at the corners. """ from ..theme import active_palette palette = active_palette() painter = QPainter(self) self._frame = {} try: painter.setRenderHint(QPainter.Antialiasing, True) rect = QRectF(self.rect()).adjusted(1.0, 1.0, -1.0, -1.0) body = QColor(palette.get("surface", palette["bg"])) from ..preferences import effective_pane_alpha opacity = effective_pane_alpha() backdrop = getattr(self.parentWidget(), "_spacr_popup_backdrop", None) if backdrop is not None and not backdrop.isHidden(): from ..preferences import _popup_backdrop_darkness opacity *= _popup_backdrop_darkness() body.setAlpha(int(round(opacity * 255))) painter.setPen(Qt.NoPen) painter.setBrush(body) painter.drawRoundedRect(QRectF(self.rect()), self._radius, self._radius) edge = QColor(palette.get("border", palette["fg"])) edge.setAlpha(235) painter.setBrush(Qt.NoBrush) painter.setPen(QPen(edge, 1.0)) painter.drawRoundedRect(rect, self._radius, self._radius) self._paint_accent(painter, QColor("#4A9EFF"), rect) finally: self._frame = None painter.end() def _rim(self, rect: QRectF): """The rounded rim ``rect`` traces, with its length. BUILT ONCE PER SIZE, not once per frame. The path is a function of the rectangle and the corner radius; neither moves while the pointer does, and measuring the length of a rounded rectangle is not free. A resize builds it again, which is the only thing that can change it. :returns: ``(path, length)``. The path is the card's own and is read from, never drawn into. """ key = (rect.x(), rect.y(), rect.width(), rect.height(), self._radius) cached = self._rim_cache if cached is None or cached[0] != key: path = QPainterPath() path.addRoundedRect(rect, self._radius, self._radius) cached = (key, path, max(1.0, path.length())) self._rim_cache = cached return cached[1], cached[2]
[docs] def accent_span(self, rect: QRectF) -> float: """How much of the rim is lit, as a fraction of its length. THE SAME FRACTION ON EVERY CARD, so a settings popup and the first-run window wear one rim rather than two takes on it. The arc preference is a length in pixels and is read as the length it should look on the REFERENCE surface; measured against each card's own perimeter instead, the same 280 px covers a sixth of the setup window and two fifths of a small popup, and the short one reads as a thick bright band -- "the rim is to thick and bright. make the rim and window look exactly like the setup spacr window." :param rect: the card's drawing rectangle; not read, because the fraction is measured on the reference card size so every card's rim looks alike. """ if self._relative_arc: from ..preferences import _rim_length_fraction return self._held("span", _rim_length_fraction) key = (self._radius, self._arc) cached = self._span_cache if cached is None or cached[0] != key: rim = QPainterPath() rim.addRoundedRect(QRectF(0.0, 0.0, *REFERENCE_CARD), self._radius, self._radius) total = max(1.0, rim.length()) cached = (key, min(0.62, max(0.04, float(self._arc) * 2.0 / total))) self._span_cache = cached return cached[1]
[docs] def accent_peak(self) -> float: """Where along the run the accent is brightest, 0..1. THE BRIGHT PART IS WHAT THE POINTER IS ON. With the run CENTRED on the pointer the peak belongs in the middle, or the brightest part sits to one side of the thing it is pointing at; with the run trailing from its head, it belongs near the front, where a wake is brightest. """ return 0.5 if self.alignment() == "centre" else 0.72
[docs] def accent_alpha(self, along: float) -> float: """Opacity at ``along`` (0 at one end, 1 at the peak), 0..1. Both ends fall to zero, so neither has an edge to arrive or leave by. Where the peak sits is :meth:`accent_peak`. :param along: position along the lit run, 0 at the tail and 1 at the head; clamped to that range. """ along = min(max(float(along), 0.0), 1.0) peak = self.accent_peak() if along <= peak: ramp = along / peak else: ramp = (1.0 - along) / max(1e-6, 1.0 - peak) return max(0.0, min(1.0, ramp ** (1.0 + self.FADE)))
[docs] def accent_start(self, span: float) -> float: """Where the lit run begins, as a fraction of the rim. With ``centre`` alignment the MIDDLE of the run lands on :attr:`position`, which is what puts the light on the pointer rather than beside it; with ``head`` the run ends there and trails backwards. :param span: length of the lit run as a fraction of the rim, as returned by :meth:`accent_span`. """ if self.alignment() == "centre": return self.position - span / 2.0 return self.position - span
def _accent_path(self, rect: QRectF) -> QPainterPath: """The lit run of rim, as one path. Kept because the SHAPE of the run is worth asserting on its own, separately from how it is shaded. THE WHOLE RIM IS BUILT ONCE and sampled along its length, so the run crosses a corner without the seam four separate corner paths have -- which is what makes it read as flowing rather than as switching. """ rim, _ = self._rim(rect) span = self.accent_span(rect) start = self.accent_start(span) out = QPainterPath() steps = 48 for index in range(steps + 1): at = (start + span * index / steps) % 1.0 point = rim.pointAtPercent(at) if index == 0: out.moveTo(point) else: out.lineTo(point) return out
[docs] def ink_at(self, along: float, accent: QColor) -> QColor: """The colour of the run at ``along`` (0 at the tail, 1 at the head). Outside `spaceout`, `glow` and `beat` retain the blue rim; `rainbow` walks the hue along the run and turns it over time, so the light carries a spectrum that moves rather than a band that sits still. UNDER ``spaceout`` THE RIM OSCILLATES IN EVERY MODE. The mark that follows the pointer is the one thing on a card that is already moving, and leaving it a fixed blue under a theme whose whole point is a moving spectrum is what the request is about. Its hue is the palette's own drift plus a much faster cycle of its own (:data:`SPACEOUT_RIM_PERIOD`), so it belongs to the same spectrum the rest of the window is travelling through instead of being a second unrelated rainbow. The shipped Beat mode retains its blue hue while brightness pulses. :param along: position along the lit run, 0 at the tail and 1 at the head. :param accent: the theme's accent colour; returned as a copy in the plain modes, and its saturation and value set the floor for the spectral ones. """ if self.spaceout(): spectral = QColor() spectral.setHsvF(self.spaceout_hue(along), min(1.0, accent.saturationF() + 0.35), max(accent.valueF(), 0.90)) return spectral if self.mode() != "rainbow": return QColor(accent) turn = self._phase / max(0.1, self.period()) hue = (float(along) + turn) % 1.0 spectral = QColor() spectral.setHsvF(hue, min(1.0, accent.saturationF() + 0.25), max(accent.valueF(), 0.85)) return spectral
[docs] def spaceout_hue(self, along: float) -> float: """Return the animated spaceout hue at a normalised rim position. :param along: Position along the rim in ``[0, 1]``. :returns: Hue-wheel position in ``[0, 1]``. """ def read(): """The current spaceout hue from the card's dressing.""" dressing = self._dressing() return float(dressing[1]()) / 360.0 if dressing else 0.0 drift = self._held("drift", read) return ((float(along) * SPACEOUT_RIM_SPREAD + self._phase / SPACEOUT_RIM_PERIOD + drift) % 1.0)
[docs] def beat(self) -> float: """A multiplier on the run's brightness, 0..1. One for every mode but `beat`, which breathes: the run brightens and dims on a steady cycle. It never reaches zero -- a rim that vanishes reads as a fault rather than as a pulse. """ if self.mode() != "beat": return 1.0 cycle = math.sin(2.0 * math.pi * self._phase / max(0.1, self.period())) return 0.45 + 0.55 * (0.5 + 0.5 * cycle)
def _paint_accent(self, painter, colour: QColor, rect: QRectF) -> None: """Fill the lit run as one band under one gradient. A BAND, NOT SEGMENTS. The run fades in width and in opacity along its length, and a pen carries one colour, so it used to be stroked as hundreds of short lines with round caps. Each cap overlapped its neighbour, the overlap was painted twice, and the rim read as a row of small boxes. Here the outline of the whole run is one polygon, so no pixel is painted twice, and its colour comes from a conical gradient centred on the card: along a convex rim the angle about the centre only ever grows, so every sample of the run is one stop and the shading between stops is continuous. WHERE THE RUN SITS ON THE POINTER is :meth:`accent_start`, and it is a setting: centred puts the middle of the light on the pointer, head puts its leading end there and trails the rest behind. """ rim, rim_px = self._rim(rect) span = self.accent_span(rect) start = self.accent_start(span) pulse = self.beat() run_px = max(1.0, span * rim_px) steps = int(min(self.MAX_STEPS, max(24.0, run_px / self.STEP_PX))) centre = rect.center() dark = rim.pointAtPercent((start + span + (1.0 - span) / 2.0) % 1.0) origin = QLineF(centre, dark).angle() outer, inner, stops = [], [], [] for index in range(steps + 1): along = index / steps at = (start + span * along) % 1.0 point = rim.pointAtPercent(at) alpha = self.accent_alpha(along) tangent = math.radians(rim.angleAtPercent(at)) half = (1.2 + 2.2 * alpha) / 2.0 normal = QPointF(math.sin(tangent) * half, math.cos(tangent) * half) outer.append(point + normal) inner.append(point - normal) turn = ((QLineF(centre, point).angle() - origin) % 360.0) / 360.0 ink = self.ink_at(along, colour) ink.setAlpha(int(round(235 * alpha * pulse))) stops.append((min(max(turn, 0.0), 1.0), ink)) stops.sort(key=lambda stop: stop[0]) gradient = QConicalGradient(centre, origin) gradient.setStops(stops) painter.setPen(Qt.NoPen) painter.setBrush(QBrush(gradient)) painter.drawPolygon(QPolygonF(outer + inner[::-1])) def _corner_path(self, rect: QRectF) -> QPainterPath: """The two edge runs meeting at the current corner, with its arc.""" radius, arc = float(self._radius), float(self._arc) path = QPainterPath() name = CORNERS[self._corner] if name == "topLeft": box = QRectF(rect.left(), rect.top(), radius * 2, radius * 2) path.moveTo(rect.left(), rect.top() + radius + arc) path.lineTo(rect.left(), rect.top() + radius) path.arcTo(box, 180, -90) path.lineTo(rect.left() + radius + arc, rect.top()) elif name == "topRight": box = QRectF(rect.right() - radius * 2, rect.top(), radius * 2, radius * 2) path.moveTo(rect.right() - radius - arc, rect.top()) path.lineTo(rect.right() - radius, rect.top()) path.arcTo(box, 90, -90) path.lineTo(rect.right(), rect.top() + radius + arc) elif name == "bottomRight": box = QRectF(rect.right() - radius * 2, rect.bottom() - radius * 2, radius * 2, radius * 2) path.moveTo(rect.right(), rect.bottom() - radius - arc) path.lineTo(rect.right(), rect.bottom() - radius) path.arcTo(box, 0, -90) path.lineTo(rect.right() - radius - arc, rect.bottom()) else: box = QRectF(rect.left(), rect.bottom() - radius * 2, radius * 2, radius * 2) path.moveTo(rect.left() + radius + arc, rect.bottom()) path.lineTo(rect.left() + radius, rect.bottom()) path.arcTo(box, 270, -90) path.lineTo(rect.left(), rect.bottom() - radius - arc) return path