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
+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()