v0.55.0: Kaleidostrobe smooth again; visualizer tile hover controls

The cones move only on the plugin's own 25 fps frames again: driving them
from fresh audio every refresh made bass-heavy songs twitch (a jump on 84%
of refreshes against 41%). The continuous turn, texture crossfade and the
GPU big window stay.

The tile: a click opens it big (and it stays blank while big); hovering
shows a colour wheel (colour / gray / off) and up/down arrows (previous /
next visualizer). No more double-click or click-to-cycle. The big album
art closes on a click or Esc, like the big visualizer.
This commit is contained in:
2026-10-14 12:51:38 -07:00
parent 7ae55e757a
commit 00aa5e7f2e
14 changed files with 414 additions and 95 deletions
+12 -5
View File
@@ -186,6 +186,12 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
share of the width. Since the Library button left the
sidebar, `MainWindow` must hand it the first focus, or the playlist tree
takes it and opens its first playlist at startup.
The transport-bar visualizer tile (`gui/visualizer.py`, Round 86): a
click opens it big, and while big the tile stays blank; hovering paints
two controls over it — a colour wheel (left: colour / gray / off) and ▲/▼
(right: previous/next style, wrapping) — hit-tested on release. No
double-click. Both big windows (visualizer, album art) close on a click
or Esc.
`track_table.py` is the shared track grid (drag/drop, copy/paste, drop
indicator). `playlist_ops.py::add_tracks_with_dup_check` is the single funnel
for every add-to-playlist path. `theme.py` applies the palette (highlight
@@ -281,11 +287,12 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
scale was never published, so `itunes_spectrum` (√magnitude, 43 Hz bins)
is the one calibrated stand-in. **On screen it is 60 fps anyway**
(Round 85): between the 25 Hz frames `_compose` turns the cones by a
fraction of a frame, crossfades the textures, and takes height/depth from
`engine.preview` — the plugin's analysis run on a throwaway copy with the
newest audio, so a beat pumps the cone one refresh later, not one 40 ms
tick later. `preview` must never touch real state (a golden test
interleaves it). Painting: the panel uses QPainter — each GL triangle is
fraction of a frame and crossfades the textures — smoothing only. The
cones' height/depth change **only on the plugin's frames**: Round 85 also
drove them from the newest audio every refresh (snappier beats), and on
bass-heavy songs it twitched — a jump on 84% of refreshes against 41% —
so Round 86 took it out. The 25 fps cap was hiding that jitter; don't
reintroduce fresh-audio geometry between frames. Painting: the panel uses QPainter — each GL triangle is
planar, drawn in texture space through a projective **world** transform
(Qt mis-draws a strongly projective *brush* transform). The **big window
replays the original's GL calls** (`paint_gl` in a `gui/kaleido_gl.py`
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.54.0"
__version__ = "0.55.0"
+17 -1
View File
@@ -30,13 +30,16 @@ def _aspect(pixmap: QPixmap | None) -> float | None:
class ArtWindow(QWidget):
"""Non-modal window showing the current track's album art as large as
possible. Just an album art window — closing it has no side effects."""
possible. Just an album art window — closing it has no side effects.
A click anywhere on the art (or Esc) closes it, like the big
visualizer."""
def __init__(self, parent=None):
super().__init__(parent, Qt.WindowType.Window)
self._pixmap: QPixmap | None = None
self.setMinimumSize(120, 120)
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, False)
self.setToolTip("Click to close")
def show_artwork(self, pixmap: QPixmap | None, title: str, screen=None):
"""Open (or re-raise) the window, sized to the art on ``screen``."""
@@ -86,3 +89,16 @@ class ArtWindow(QWidget):
x = area.x() + (area.width() - scaled.width()) // 2
y = area.y() + (area.height() - scaled.height()) // 2
painter.drawPixmap(x, y, scaled)
def mouseReleaseEvent(self, event):
if (event.button() == Qt.MouseButton.LeftButton
and self.contentsRect().contains(event.position().toPoint())):
self.close()
return
super().mouseReleaseEvent(event)
def keyPressEvent(self, event):
if event.key() == Qt.Key.Key_Escape:
self.close()
return
super().keyPressEvent(event)
+1 -6
View File
@@ -75,12 +75,7 @@ def _fade_text(seconds: float) -> str:
# visualizer_styles' style keys, as the radios name them.
_VISUALIZER_STYLES = {
"bars": "Bars",
"radial": "Radial dots",
"laser": "Laser",
"kaleidostrobe": "Kaleidostrobe",
}
_VISUALIZER_STYLES = visualizer_styles.STYLE_NAMES
def _sync_text(ms: int) -> str:
+154 -27
View File
@@ -11,8 +11,8 @@ import time
import numpy as np
from PyQt6.QtWidgets import QWidget
from PyQt6.QtCore import Qt, QTimer, QRectF, pyqtSignal
from PyQt6.QtGui import QPainter, QColor
from PyQt6.QtCore import Qt, QTimer, QRectF, QPointF, pyqtSignal
from PyQt6.QtGui import QPainter, QColor, QConicalGradient, QPen, QPolygonF
from PyQt6.QtMultimedia import QAudioFormat
from lintunes import theme
@@ -20,7 +20,7 @@ from lintunes.config import load_config
from lintunes.gui.icons import transport_icon
from lintunes.gui import visualizer_styles as styles
from lintunes.gui.visualizer_styles import (
BANDS, HISTORY, STYLES, STYLE_BARS, Frame, make_renderer)
BANDS, HISTORY, STYLES, STYLE_BARS, STYLE_NAMES, Frame, make_renderer)
FRAME_MS = 16 # ~60 fps
@@ -30,13 +30,16 @@ HISTORY_S = 1.5 # decoded audio kept, so a frame can pick what's *heard*
PENDING_S = 0.25 # the laser catches up at most this much at once
GAP_MS = 50 # a buffer this far from the last one's end is a seek
# Visualizer click cycles through these brightness modes in order.
# The colour-wheel button cycles through these modes in order.
MODE_ON = "on"
MODE_DIM = "dim"
MODE_OFF = "off"
_MODE_CYCLE = (MODE_ON, MODE_DIM, MODE_OFF)
_MODE_NAMES = {MODE_ON: "colour", MODE_DIM: "gray", MODE_OFF: "off"}
_TOOLTIP = "Click to cycle: on / dim / off — double-click to enlarge"
_TOOLTIP = "Click to see it big"
BUTTON = 24 # the hover buttons: colour wheel left, ▲/▼ right
BUTTON_MARGIN = 4
_SAMPLE_DTYPES = {
QAudioFormat.SampleFormat.UInt8: (np.uint8, 127.5, 127.5),
@@ -52,11 +55,13 @@ def _style(prefs) -> str:
class VisualizerWidget(QWidget):
"""Animated visualizer; click to cycle brightness: on / dim / off, and
double-click to open it big (``VisualizerWindow``).
"""Animated visualizer. Hovering shows two controls over it — a colour
wheel (left) that cycles colour / gray / off, and ▲/▼ (right) that step
through the styles; a click anywhere else opens it big
(``VisualizerWindow``), and while it is big the panel stays blank.
On = highlight-colored; dim = light gray just darker than the panel;
off = blank (the big window, if open, keeps going in color).
Colour = highlight-colored; gray = light gray just darker than the panel;
off = blank (the big window, if open, keeps going).
While casting there is no local PCM to analyze, so the panel doubles as the
cast indicator: it shows the device glyph and a click stops casting.
@@ -70,7 +75,6 @@ class VisualizerWidget(QWidget):
self._prefs = prefs
saved = prefs.get("visualizer_mode") if prefs is not None else None
self._mode = saved if saved in _MODE_CYCLE else MODE_ON
self._mode_before_press = self._mode
self._renderer = make_renderer(_style(prefs), styles.settings(prefs))
# Decoded audio, newest last, and the stream time its end belongs
# to. Decoding runs ahead of the speakers — normally by a buffer or
@@ -93,6 +97,9 @@ class VisualizerWidget(QWidget):
self.setFixedWidth(140)
self.setMinimumHeight(36)
self.setToolTip(_TOOLTIP)
self.setMouseTracking(True) # which hover button is under the mouse
self._hovering = False
self._hot = None # "mode" / "prev" / "next" / None
self._timer = QTimer(self)
self._timer.setInterval(FRAME_MS)
@@ -270,29 +277,143 @@ class VisualizerWidget(QWidget):
if self._mode == MODE_OFF and not self._big_visible():
self._clear() # off = blank, not the last frame held in place
self._sync_timer()
self._update_tooltip()
self.update()
def mousePressEvent(self, event):
if self._no_pcm:
# While casting the panel is the cast indicator, not a spectrum:
# cycling its brightness would be meaningless, so a click stops
# casting instead.
self.cast_stop_requested.emit()
return
# Cycle on -> dim -> off -> on.
self._mode_before_press = self._mode
def cycle_mode(self):
"""The colour wheel: colour → gray → off → colour."""
idx = _MODE_CYCLE.index(self._mode)
self._set_mode(_MODE_CYCLE[(idx + 1) % len(_MODE_CYCLE)])
def mouseDoubleClickEvent(self, event):
"""Qt sends press, release, *double-click*: the first press already
cycled the brightness, so put it back before opening big. A brief
flicker, in exchange for a single click that never waits to see
whether a second one is coming."""
def step_style(self, step: int):
"""▲/▼: the previous or next style, wrapping around."""
current = _style(self._prefs) if self._prefs is not None else self._renderer.name
idx = STYLES.index(current) if current in STYLES else 0
style = STYLES[(idx + step) % len(STYLES)]
if self._prefs is not None:
self._prefs.set("visualizer_style", style) # swaps the renderer
else:
self._renderer = make_renderer(style, styles.settings(None))
self._update_tooltip()
self.update()
# ---- the hover controls ----
def _button_rects(self) -> dict:
w, h = self.width(), self.height()
m = BUTTON_MARGIN
arrow_h = min(BUTTON, (h - 3 * m) / 2)
right = w - m - BUTTON
return {
"mode": QRectF(m, (h - BUTTON) / 2, BUTTON, BUTTON),
"prev": QRectF(right, h / 2 - m / 2 - arrow_h, BUTTON, arrow_h),
"next": QRectF(right, h / 2 + m / 2, BUTTON, arrow_h),
}
def _hit(self, pos):
if self._no_pcm:
return # the first click already stopped casting
self._set_mode(self._mode_before_press)
self.open_big()
return None
for name, rect in self._button_rects().items():
if rect.contains(QPointF(pos)):
return name
return None
def _update_tooltip(self):
if self._no_pcm:
return
current = _style(self._prefs) if self._prefs is not None else self._renderer.name
idx = STYLES.index(current) if current in STYLES else 0
tips = {
"mode": f"Colour / gray / off — now {_MODE_NAMES[self._mode]}",
"prev": f"Previous visualizer: {STYLE_NAMES[STYLES[idx - 1]]}",
"next": f"Next visualizer: {STYLE_NAMES[STYLES[(idx + 1) % len(STYLES)]]}",
}
self.setToolTip(tips.get(self._hot, _TOOLTIP))
def enterEvent(self, event):
self._hovering = True
self.update()
super().enterEvent(event)
def leaveEvent(self, event):
self._hovering = False
self._hot = None
self.update()
super().leaveEvent(event)
def mouseMoveEvent(self, event):
hot = self._hit(event.position())
if hot != self._hot:
self._hot = hot
self._update_tooltip()
self.update()
super().mouseMoveEvent(event)
def mousePressEvent(self, event):
if self._no_pcm:
# While casting the panel is the cast indicator, not a picture:
# a click stops casting instead.
self.cast_stop_requested.emit()
return
event.accept() # acted on at release, like a button
def mouseReleaseEvent(self, event):
if self._no_pcm or event.button() != Qt.MouseButton.LeftButton:
return
if not self.rect().contains(event.position().toPoint()):
return # dragged off: no click
hit = self._hit(event.position())
if hit == "mode":
self.cycle_mode()
elif hit == "prev":
self.step_style(-1)
elif hit == "next":
self.step_style(1)
else:
self.open_big()
def _paint_controls(self, painter):
"""The hover buttons, over whatever is showing."""
pal = self.palette()
face = QColor(pal.window().color())
ink = QColor(pal.windowText().color())
painter.save()
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
for name, rect in self._button_rects().items():
fill = QColor(face)
fill.setAlphaF(0.92 if name == self._hot else 0.72)
painter.setPen(QPen(QColor(ink.red(), ink.green(), ink.blue(),
110 if name == self._hot else 50), 1))
painter.setBrush(fill)
painter.drawRoundedRect(rect.adjusted(0.5, 0.5, -0.5, -0.5), 5, 5)
# colour wheel: a hue ring (colour), a gray one (gray), an outline (off)
wheel = self._button_rects()["mode"].adjusted(5, 5, -5, -5)
center = wheel.center()
if self._mode == MODE_OFF:
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.setPen(QPen(ink, 1.5))
else:
grad = QConicalGradient(center, 90)
for k in range(7):
c = QColor.fromHsvF((k / 6) % 1.0, 0.85, 0.95)
if self._mode == MODE_DIM: # a gray sweep, light to dark
v = int(225 - 150 * abs(k - 3) / 3)
c = QColor(v, v, v)
grad.setColorAt(k / 6, c)
painter.setBrush(grad)
painter.setPen(QPen(QColor(0, 0, 0, 90), 1))
painter.drawEllipse(wheel)
# ▲ / ▼
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(ink)
for name, up in (("prev", True), ("next", False)):
r = self._button_rects()[name]
cx, cy, s = r.center().x(), r.center().y(), min(r.width(), r.height()) * 0.22
tip = -s if up else s
painter.drawPolygon(QPolygonF([QPointF(cx - s * 1.2, cy - tip * 0.6),
QPointF(cx + s * 1.2, cy - tip * 0.6),
QPointF(cx, cy + tip * 0.9)]))
painter.restore()
def open_big(self):
from lintunes.gui.visualizer_window import VisualizerWindow
@@ -300,11 +421,13 @@ class VisualizerWidget(QWidget):
self._big = VisualizerWindow(self)
self._big.open(self.screen())
self._sync_timer()
self.update() # the panel goes blank while it is big
def big_window_closed(self):
if self._mode == MODE_OFF:
self._clear()
self._sync_timer()
self.update()
# ---- frame computation ----
@@ -348,6 +471,8 @@ class VisualizerWidget(QWidget):
regardless (in color), since opening it is asking to see it."""
if self._no_pcm or (self._mode == MODE_OFF and not big):
return
if not big and self._big_visible():
return # it's showing big; once is enough
self._tell_mode()
self._renderer.paint(painter, rect, self._bar_color(), self._grid_gray())
@@ -388,3 +513,5 @@ class VisualizerWidget(QWidget):
painter.end()
return
self.paint_renderer(painter, QRectF(self.rect()))
if self._hovering:
self._paint_controls(painter)
+8 -8
View File
@@ -10,10 +10,11 @@ way its OpenGL did.
- **The screen's clock.** Between those frames the picture is still drawn
at 60 fps (``advance`` → ``_compose``): the turn continues smoothly (the
rotation is a frame count, so a fraction of a frame is well defined), the
textures crossfade from the last frame to the newest, and the cones'
height and depth come from the plugin's own analysis run on the newest
audio (``engine.preview``) — so a beat pumps the cone on the next screen
refresh rather than at the next 40 ms tick.
textures crossfade from the last frame to the newest. The cones' height
and depth stay the plugin's own, changing only on its frames: driving
them from the newest audio at every refresh (Round 85) made the picture
twitch on bass-heavy songs — on 84% of refreshes against 41% — which is
the jitter the 25 fps cap had been hiding all along.
- **Input.** iTunes handed plugins 512 spectrum bins per channel as bytes;
how it scaled them was never documented, so ``itunes_spectrum`` is a
calibrated stand-in (``SPECTRUM_GAIN``).
@@ -162,20 +163,19 @@ class KaleidostrobeRenderer:
self._frame = self._engine.step(spectrum, self._ticks())
if self._frame is None:
return False
self._compose(spectrum)
self._compose()
return True
def _ticks(self, ahead=0.0):
return 1000 + int((self._clock + ahead) * ks.TICKS_PER_S)
def _compose(self, spectrum):
def _compose(self):
"""This screen refresh's picture, a fraction ``t`` of the way from
the plugin's last frame to its next one."""
e = self._engine
t = self._behind / STEP_S
mid, signal = e.preview(spectrum, self._ticks(self._behind))
drawn = (e.rotation - 1) % ks.ROTATION_STEPS # the last frame's turn
layers = e.layers(mid, signal, drawn + t)
layers = e.layers(e.mid, e.avg_signal, drawn + t)
cur = self._frame.textures
prev = self._prev_textures if self._prev_textures is not None else cur
if t > 0.0 and prev is not cur:
+6
View File
@@ -33,6 +33,12 @@ STYLE_RADIAL = "radial"
STYLE_LASER = "laser"
STYLE_KALEIDOSTROBE = "kaleidostrobe"
STYLES = (STYLE_BARS, STYLE_RADIAL, STYLE_LASER, STYLE_KALEIDOSTROBE)
STYLE_NAMES = {
STYLE_BARS: "Bars",
STYLE_RADIAL: "Radial dots",
STYLE_LASER: "Laser",
STYLE_KALEIDOSTROBE: "Kaleidostrobe",
}
BANDS = 20
STEPS = 20 # vertical granularity per bar
-15
View File
@@ -11,7 +11,6 @@ original handed OpenGL: two 128×128 RGBA textures and, per layer, the
triangles to draw (object-space vertices, texture coordinates, and the
modelview that places them). ``visualizer_kaleido`` paints that.
"""
import copy
import math
import struct
from dataclasses import dataclass, field
@@ -611,20 +610,6 @@ class Kaleidostrobe:
self.top_object, height, f32(depth * 5.0)))
return [bottom, top]
def preview(self, spectrum, ticks):
"""What the plugin's analysis would say if it rendered now: (mid,
avg_signal) from a throwaway copy, so nothing here is changed. The
renderer draws between the plugin's 25 fps frames with these, so a
beat moves the cone on the next screen refresh instead of up to
40 ms later; at the next real frame the numbers are the same."""
ghost = copy.copy(self)
ghost.band_peak = [list(row) for row in self.band_peak]
ghost.lp = {k: list(v) for k, v in self.lp.items()}
ghost.beat_periods = list(self.beat_periods)
ghost.frame_count += 1
ghost._process(spectrum, ticks)
return ghost.mid, ghost.avg_signal
def _object(self, k, h, z):
if k == 0:
return self._draw8(h, z)
+1 -1
View File
@@ -68,7 +68,7 @@ class TestVisualizerModePersistence:
prefs = Preferences(tmp_path)
vis = VisualizerWidget(_FakePlayer(), prefs)
vis.mousePressEvent(None) # on -> dim
vis.cycle_mode() # on -> dim
assert prefs.get("visualizer_mode") == MODE_DIM
# A fresh Preferences (= next launch) sees it too.
+3 -3
View File
@@ -900,8 +900,8 @@ class TestTransportLayout:
vis = bar._visualizer
before = vis._mode
# Not casting: a click still cycles the brightness as it always did.
vis.mousePressEvent(None)
# Not casting: the colour wheel cycles the brightness.
vis.cycle_mode()
assert vis._mode != before
cast.disconnect.assert_not_called()
@@ -1020,4 +1020,4 @@ class TestVisualizerWhileCasting:
assert "click to stop" in vis.toolTip()
player.set_sink(None)
assert "cycle" in vis.toolTip() # back to the brightness control
assert "big" in vis.toolTip() # back to the tile's own tooltip
+2 -3
View File
@@ -432,11 +432,10 @@ class TestBarsFade:
# --------------------------------------------------------------------------
class TestBigWindow:
def test_double_click_keeps_the_brightness_and_opens(self, qapp, tmp_path):
def test_opening_keeps_the_brightness(self, qapp, tmp_path):
prefs = Preferences(tmp_path)
vis = VisualizerWidget(_FakePlayer(playing=True), prefs)
vis.mousePressEvent(None) # Qt's first press: on -> dim
vis.mouseDoubleClickEvent(None) # …which the double-click undoes
vis.open_big() # Round 85: a single click on the tile
try:
assert vis._mode == MODE_ON
assert prefs.get("visualizer_mode") == MODE_ON
+9 -16
View File
@@ -1,9 +1,9 @@
"""Round 85: Kaleidostrobe at 60 fps, its big window on the GPU, gray mode
as two tones, and a sync slider that can also say "sooner".
1. Between the plugin's 25 fps frames: a smooth turn, crossfaded textures,
the cone driven by a preview of the newest audio — which must never
change the original's own frames.
1. Between the plugin's 25 fps frames: a smooth turn and crossfaded
textures. (The cone also followed a preview of the newest audio here;
Round 86 took that out — it made the picture twitch.)
2. Gray mode: the gray-mode colour on the panel background, nothing else.
3. The sync slider below zero.
4. The big window: an OpenGL canvas for Kaleidostrobe, QPainter without GL.
@@ -17,24 +17,17 @@ from lintunes.gui import visualizer_styles as vs
from lintunes.gui.visualizer_kaleido import KaleidostrobeRenderer
from lintunes.preferences import Preferences
from test_round84 import GOLDEN, _digest, _frame, _paint, _FakePlayer, _LastFm
from test_round84 import _frame, _paint, _FakePlayer, _LastFm
SEED = 7
class TestBetweenFrames:
def test_previews_between_frames_change_nothing(self):
"""The renderer asks for a preview every screen refresh; the frames
must still be the original's."""
engine = ks.Kaleidostrobe(seed=1000, ticks=1000)
for f, (spectrum, ticks) in enumerate(zip(GOLDEN["spectra"], GOLDEN["ticks"])):
engine.preview(spectrum, int(ticks) + 1)
engine.preview(GOLDEN["spectra"][-1 - f], int(ticks) + 2)
frame = engine.step(spectrum, int(ticks))
assert _digest(frame.textures[0]) == GOLDEN["tex_digest"][f][0], f"frame {f}"
assert _digest(frame.textures[1]) == GOLDEN["tex_digest"][f][1], f"frame {f}"
def test_gray_mode_is_two_tone(self, qapp):
"""Only the gray-mode colour and the panel's background, blended."""
r = KaleidostrobeRenderer()
r._engine = ks.Kaleidostrobe(seed=SEED, ticks=1000)
for _ in range(5):
r.advance(_frame(dt=0.04))
r.dimmed = True # the widget then passes its gray
@@ -44,7 +37,7 @@ class TestBetweenFrames:
k = (img - ground) / (ink - ground) # 0 = ground, 1 = ink
assert np.ptp(k, axis=-1).max() < 0.03 # on the line between
assert k.min() > -0.02 and k.max() < 1.02
assert k.max() - k.min() > 0.3 # and still a picture
assert k.max() - k.min() > 0.15 # and still a picture
def test_draws_between_the_plugins_frames(self, qapp):
"""60 fps on screen: the turn moves every refresh, frames at 25."""
+191
View File
@@ -0,0 +1,191 @@
"""Round 86: Kaleidostrobe smooth again, and the visualizer tile's controls.
1. The cones move only on the plugin's own 25 fps frames (driving them from
fresh audio at every refresh, Round 85, made bass-heavy songs twitch).
2. The tile: hovering shows a colour wheel (colour / gray / off) and ▲/▼
(style); a click anywhere else opens it big; no double-click any more.
3. While it's big, the tile stays blank.
4. The big album art closes on a click, like the big visualizer.
"""
from unittest.mock import MagicMock
from PyQt6.QtCore import QEvent, QObject, QPointF, Qt, pyqtSignal
from PyQt6.QtGui import QColor, QImage, QKeyEvent, QMouseEvent, QPainter
from lintunes.gui import visualizer_styles as vs
from lintunes.gui.visualizer import MODE_DIM, MODE_OFF, MODE_ON, VisualizerWidget
from lintunes.gui.visualizer_kaleido import KaleidostrobeRenderer
from lintunes.preferences import Preferences
from test_round84 import _frame
class _FakePlayer(QObject):
audio_buffer = pyqtSignal(object)
playing_changed = pyqtSignal(bool)
track_changed = pyqtSignal(object)
def is_playing(self):
return False
def _click(widget, pos, button=Qt.MouseButton.LeftButton):
pos = QPointF(pos)
for kind, buttons in ((QEvent.Type.MouseButtonPress, button),
(QEvent.Type.MouseButtonRelease, Qt.MouseButton.NoButton)):
event = QMouseEvent(kind, pos, pos, button, buttons,
Qt.KeyboardModifier.NoModifier)
if kind == QEvent.Type.MouseButtonPress:
widget.mousePressEvent(event)
else:
widget.mouseReleaseEvent(event)
def _tile(tmp_path, style="bars"):
prefs = Preferences(tmp_path)
prefs.set("visualizer_style", style)
vis = VisualizerWidget(_FakePlayer(), prefs)
vis.resize(140, 62)
return prefs, vis
# --------------------------------------------------------------------------
# 1. Smooth again
# --------------------------------------------------------------------------
class TestSmooth:
def test_the_cone_moves_only_on_the_plugins_frames(self, qapp):
"""Height and depth are the plugin's own: driving them from fresh
audio every refresh made the picture twitch."""
r = KaleidostrobeRenderer()
r.advance(_frame(dt=0.04))
seen = []
for i in range(30):
r.advance(_frame(dt=1 / 60, hz=55 + 40 * (i % 3)))
apex = r._shown[0][0].triangles[0].xyz[0][2]
seen.append((r._engine.frame_count, apex))
for (fa, za), (fb, zb) in zip(seen, seen[1:]):
if fa == fb:
assert za == zb
# --------------------------------------------------------------------------
# 2. The tile's controls
# --------------------------------------------------------------------------
class TestTile:
def test_a_click_in_the_middle_opens_big(self, qapp, tmp_path):
prefs, vis = _tile(tmp_path)
_click(vis, vis.rect().center())
try:
assert vis._big is not None and vis._big.isVisible()
assert vis._mode == MODE_ON # brightness untouched
finally:
vis._big.close()
def test_the_colour_wheel_cycles_and_does_not_open(self, qapp, tmp_path):
prefs, vis = _tile(tmp_path)
wheel = vis._button_rects()["mode"].center()
seen = []
for _ in range(3):
_click(vis, wheel)
seen.append(vis._mode)
assert seen == [MODE_DIM, MODE_OFF, MODE_ON]
assert prefs.get("visualizer_mode") == MODE_ON
assert vis._big is None
def test_arrows_step_through_every_style_and_wrap(self, qapp, tmp_path):
prefs, vis = _tile(tmp_path, "bars")
down = vis._button_rects()["next"].center()
up = vis._button_rects()["prev"].center()
seen = []
for _ in range(len(vs.STYLES)):
_click(vis, down)
seen.append(prefs.get("visualizer_style"))
assert seen == list(vs.STYLES[1:]) + [vs.STYLES[0]]
_click(vis, up) # bars → the last one
assert prefs.get("visualizer_style") == vs.STYLES[-1]
assert isinstance(vis._renderer, KaleidostrobeRenderer)
assert vis._big is None
def test_a_double_click_does_nothing_special(self, qapp, tmp_path):
assert "mouseDoubleClickEvent" not in VisualizerWidget.__dict__
def test_controls_show_only_on_hover(self, qapp, tmp_path):
prefs, vis = _tile(tmp_path)
prefs.set("visualizer_mode", MODE_OFF)
def shot():
return vis.grab().toImage().pixel(int(vis._button_rects()["mode"].center().x()),
int(vis._button_rects()["mode"].center().y()))
plain = shot()
vis._hovering = True
assert shot() != plain
vis._hovering = False
assert shot() == plain
def test_tooltips_say_what_each_part_does(self, qapp, tmp_path):
prefs, vis = _tile(tmp_path, "bars")
vis._hot = "next"
vis._update_tooltip()
assert "Radial dots" in vis.toolTip()
vis._hot = "mode"
vis._update_tooltip()
assert "colour" in vis.toolTip()
vis._hot = None
vis._update_tooltip()
assert "big" in vis.toolTip()
def test_casting_click_still_stops_casting(self, qapp, tmp_path):
prefs, vis = _tile(tmp_path)
stopped = MagicMock()
vis.cast_stop_requested.connect(stopped)
vis._no_pcm = True
_click(vis, vis._button_rects()["mode"].center())
stopped.assert_called_once()
assert vis._mode == MODE_ON
# --------------------------------------------------------------------------
# 3. Blank while big
# --------------------------------------------------------------------------
class TestBlankWhileBig:
def test_tile_draws_nothing_while_big_is_open(self, qapp, tmp_path):
prefs, vis = _tile(tmp_path)
vis._renderer = MagicMock(spec=["paint", "advance", "clear", "name"])
img = QImage(140, 62, QImage.Format.Format_ARGB32)
painter = QPainter(img)
vis.paint_renderer(painter, vis.rect())
assert vis._renderer.paint.call_count == 1
vis.open_big()
try:
vis.paint_renderer(painter, vis.rect()) # the tile
assert vis._renderer.paint.call_count == 1
vis.paint_renderer(painter, vis.rect(), big=True) # the window
assert vis._renderer.paint.call_count == 2
finally:
painter.end()
vis._big.close()
# --------------------------------------------------------------------------
# 4. Big album art
# --------------------------------------------------------------------------
class TestArtWindow:
def test_a_click_closes_it(self, qapp):
from lintunes.gui.art_window import ArtWindow
win = ArtWindow()
win.resize(300, 300)
win.show()
_click(win, win.contentsRect().center())
assert not win.isVisible()
def test_escape_closes_it(self, qapp):
from lintunes.gui.art_window import ArtWindow
win = ArtWindow()
win.show()
win.keyPressEvent(QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_Escape,
Qt.KeyboardModifier.NoModifier))
assert not win.isVisible()
+9 -9
View File
@@ -80,11 +80,11 @@ class TestVisualizerToggle:
vis = VisualizerWidget(player)
assert vis._mode == MODE_ON
vis.mousePressEvent(None) # on -> dim
vis.cycle_mode() # on -> dim
assert vis._mode == MODE_DIM
vis.mousePressEvent(None) # dim -> off
vis.cycle_mode() # dim -> off
assert vis._mode == MODE_OFF
vis.mousePressEvent(None) # off -> on (wraps)
vis.cycle_mode() # off -> on (wraps)
assert vis._mode == MODE_ON
def test_off_goes_blank_and_stops(self, qapp):
@@ -94,8 +94,8 @@ class TestVisualizerToggle:
vis = VisualizerWidget(player)
vis._bars = [7] * BANDS # pretend mid-animation
vis.mousePressEvent(None) # on -> dim
vis.mousePressEvent(None) # dim -> off
vis.cycle_mode() # on -> dim
vis.cycle_mode() # dim -> off
assert vis._mode == MODE_OFF
assert vis._bars == [0] * BANDS # blank, not the held frame
@@ -108,7 +108,7 @@ class TestVisualizerToggle:
player._playing = True
vis = VisualizerWidget(player)
vis.mousePressEvent(None) # on -> dim
vis.cycle_mode() # on -> dim
assert vis._mode == MODE_DIM
assert vis._timer.isActive() # dim animates while playing
@@ -119,9 +119,9 @@ class TestVisualizerToggle:
player._playing = True
vis = VisualizerWidget(player)
vis.mousePressEvent(None) # on -> dim
vis.mousePressEvent(None) # dim -> off
vis.mousePressEvent(None) # off -> on again
vis.cycle_mode() # on -> dim
vis.cycle_mode() # dim -> off
vis.cycle_mode() # off -> on again
assert vis._mode == MODE_ON
assert vis._timer.isActive()