Skip to content
143 changes: 143 additions & 0 deletions napari_animation/_qt/_tests/test_play.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
from contextlib import contextmanager

import numpy as np
import pytest
from napari._qt._constants import LoopMode

from napari_animation._qt import AnimationWidget
from napari_animation._qt.frameslider_widget import AnimationMovieWorker


@pytest.fixture()
def animation_widget(qtbot, make_napari_viewer):
"""basic AnimationWidget with data that we will use a few times"""
viewer = make_napari_viewer()
viewer.add_image(np.random.random((28, 28)))
aw = AnimationWidget(viewer)
qtbot.addWidget(aw)

aw.animation.capture_keyframe()
nz = 8
aw.animation.capture_keyframe(steps=nz - 1)
aw.animation._frames._current_index = 0

return aw


@contextmanager
def make_worker(
qtbot,
animation_widget,
nframes=8,
fps=20,
frame_range=None,
loop_mode=LoopMode.LOOP,
):
# sets up an AnimationMovieWorker ready for testing, and breaks down when done

aw = animation_widget

slider_widget = aw.frameSliderWidget
slider_widget.loop_mode = loop_mode
slider_widget.fps = fps
slider_widget.frame_range = frame_range

worker = AnimationMovieWorker(slider_widget)
worker._count = 0
worker.nz = len(aw.animation._frames)

def bump(*args):
if worker._count < nframes:
worker._count += 1
else:
worker.finish()

def count_reached():
assert worker._count >= nframes

def go():
worker.work()
qtbot.waitUntil(count_reached, timeout=6000)
return worker.current

worker.frame_requested.connect(bump)
worker.go = go

yield worker


# Each tuple represents different arguments we will pass to make_thread
# frames, fps, mode, frame_range, expected_result(nframes, nz)
CONDITIONS = [
# regular nframes < nz
(5, 10, LoopMode.LOOP, None, lambda x, y: x),
# loops around to the beginning
(10, 10, LoopMode.LOOP, None, lambda x, y: x % y),
# loops correctly with frame_range specified
(10, 10, LoopMode.LOOP, (2, 6), lambda x, y: x % y),
# loops correctly going backwards
(10, -10, LoopMode.LOOP, None, lambda x, y: y - (x % y)),
# loops back and forth
(10, 10, LoopMode.BACK_AND_FORTH, None, lambda x, y: x - y + 2),
# loops back and forth, with negative fps
(10, -10, LoopMode.BACK_AND_FORTH, None, lambda x, y: y - (x % y) - 2),
]


@pytest.mark.parametrize("nframes,fps,mode,rng,result", CONDITIONS)
def test_animation_thread_variants(
qtbot, animation_widget, nframes, fps, mode, rng, result
):
"""This is mostly testing that AnimationMovieWorker.advance works as expected"""
with make_worker(
qtbot,
animation_widget,
fps=fps,
nframes=nframes,
frame_range=rng,
loop_mode=mode,
) as worker:
current = worker.go()
if rng:
nrange = rng[1] - rng[0] + 1
expected = rng[0] + result(nframes, nrange)
assert expected - 1 <= current <= expected + 1
else:
expected = result(nframes, worker.nz)
# assert current == expected
# relaxing for CI OSX tests
assert expected - 1 <= current <= expected + 1


def test_animation_thread_once(qtbot, animation_widget):
"""Single shot animation should stop when it reaches the last frame"""
nframes = 13
with make_worker(
qtbot, animation_widget, nframes=nframes, loop_mode=LoopMode.ONCE
) as worker:
with qtbot.waitSignal(worker.finished, timeout=8000):
worker.work()
assert worker.current == worker.nz


def test_play_raises_index_errors(qtbot, animation_widget):

# data doesn't have 20 frames
with pytest.raises(IndexError):
animation_widget.frameSliderWidget._play(20, frame_range=[2, 20])
qtbot.wait(20)
animation_widget.frameSliderWidget._stop()


def test_play_raises_value_errors(qtbot, animation_widget):
# frame_range[1] not > frame_range[0]
with pytest.raises(ValueError):
animation_widget.frameSliderWidget._play(20, frame_range=[2, 2])
qtbot.wait(20)
animation_widget.frameSliderWidget._stop()

# that's not a valid loop_mode
with pytest.raises(ValueError):
animation_widget.frameSliderWidget._play(20, loop_mode=5)
qtbot.wait(20)
animation_widget.frameSliderWidget._stop()
42 changes: 6 additions & 36 deletions napari_animation/_qt/animation_widget.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,11 @@
from pathlib import Path

from napari import Viewer
from qtpy.QtCore import Qt
from qtpy.QtWidgets import (
QErrorMessage,
QPushButton,
QSlider,
QVBoxLayout,
QWidget,
)
from qtpy.QtWidgets import QErrorMessage, QPushButton, QVBoxLayout, QWidget

from ..animation import Animation
from .frame_widget import FrameWidget
from .frameslider_widget import QtFrameSliderWidget
from .keyframelistcontrol_widget import KeyFrameListControlWidget
from .keyframeslist_widget import KeyFramesListWidget
from .savedialog_widget import SaveDialogWidget
Expand Down Expand Up @@ -49,17 +43,17 @@ def __init__(self, viewer: Viewer, parent=None):
self.frameWidget = FrameWidget(parent=self)
self.saveButton = QPushButton("Save Animation", parent=self)
self.saveButton.setEnabled(len(self.animation.key_frames) > 1)
self.animationSlider = QSlider(Qt.Horizontal, parent=self)
self.animationSlider.setToolTip("Scroll through animation")
self.animationSlider.setRange(0, len(self.animation._frames) - 1)
self.frameSliderWidget = QtFrameSliderWidget(
parent=self, frames=self.animation._frames, viewer=self.viewer
)

# Create layout
self.setLayout(QVBoxLayout())
self.layout().addWidget(self.keyframesListControlWidget)
self.layout().addWidget(self.keyframesListWidget)
self.layout().addWidget(self.frameWidget)
self.layout().addWidget(self.saveButton)
self.layout().addWidget(self.animationSlider)
self.layout().addWidget(self.frameSliderWidget)

# establish key bindings and callbacks
self._add_keybind_callbacks()
Expand All @@ -86,8 +80,6 @@ def _add_callbacks(self):
self._capture_keyframe_callback
)
self.saveButton.clicked.connect(self._save_callback)
self.animationSlider.valueChanged.connect(self._on_slider_moved)
self.animation._frames.events.n_frames.connect(self._nframes_changed)

keyframe_list = self.animation.key_frames
keyframe_list.events.inserted.connect(self._on_keyframes_changed)
Expand All @@ -96,9 +88,6 @@ def _add_callbacks(self):
keyframe_list.selection.events.active.connect(
self._on_active_keyframe_changed
)
self.animation._frames.events._current_index.connect(
self._on_frame_index_changed
)

def _input_state(self):
"""Get current state of input widgets as {key->value} parameters."""
Expand Down Expand Up @@ -131,26 +120,13 @@ def _on_keyframes_changed(self, event=None):
self.frameWidget.setEnabled(has_frames)
self.saveButton.setEnabled(n_keyframes > 1)

def _on_frame_index_changed(self, event=None):
"""Callback on change of last set frame index."""
frame_index = event.value
self.animationSlider.blockSignals(True)
self.animationSlider.setValue(frame_index)
self.animationSlider.blockSignals(False)

def _on_active_keyframe_changed(self, event):
"""Callback on change of active keyframe in the key frames list."""
active_keyframe = event.value
self.keyframesListControlWidget.deleteButton.setEnabled(
bool(active_keyframe)
)

def _on_slider_moved(self, event=None):
frame_index = event
if frame_index < len(self.animation._frames):
with self.animation.key_frames.selection.events.active.blocker():
self.animation.set_movie_frame_index(frame_index)

def _save_callback(self, event=None):

filters = (
Expand Down Expand Up @@ -178,12 +154,6 @@ def _save_callback(self, event=None):
error_dialog.showMessage(str(err))
error_dialog.exec_()

def _nframes_changed(self, event):
has_frames = bool(event.value)
self.animationSlider.setEnabled(has_frames)
self.animationSlider.blockSignals(has_frames)
self.animationSlider.setMaximum(event.value - 1 if has_frames else 0)

def closeEvent(self, ev) -> None:
# release callbacks
for key, _ in self._keybindings:
Expand Down
Loading