Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/pytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ on:
jobs:
pytest:
runs-on: ubuntu-latest
env:
QT_QPA_PLATFORM: offscreen
strategy:
fail-fast: false
matrix:
Expand All @@ -28,6 +30,11 @@ jobs:
python -m pip install --upgrade pip
pip install poetry

- name: Install Qt runtime dependencies
run: |
sudo apt-get update
sudo apt-get install -y libegl1 libopengl0

- name: Install dependencies
run: poetry install --with dev

Expand Down
71 changes: 62 additions & 9 deletions bitcoin_safe_lib/gui/qt/spinning_button.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@
import sys
from functools import partial

from PyQt6.QtCore import QByteArray, QRectF, QSize, Qt, QTimer, pyqtSignal
from PyQt6.QtGui import QIcon, QPainter, QPixmap
from PyQt6 import sip
from PyQt6.QtCore import QByteArray, QEvent, QRectF, QSize, Qt, QTimer, pyqtSignal
from PyQt6.QtGui import QCloseEvent, QIcon, QPainter, QPixmap
from PyQt6.QtSvg import QSvgRenderer
from PyQt6.QtWidgets import QApplication, QMainWindow, QPushButton, QVBoxLayout, QWidget

Expand Down Expand Up @@ -88,6 +89,8 @@ def __init__(
spinning_svg_content = (
svg_tools or SvgTools(get_icon_path=partial(resource_path, "icons"), theme_file=None)
).auto_theme_svg(spinning_svg_content if spinning_svg_content else DEFAULT_SPINNER_SVG)
if spinning_svg_content is None:
raise ValueError("Invalid SVG content provided")
self.svg_renderer = QSvgRenderer(QByteArray(spinning_svg_content.encode("utf-8")))

if not self.svg_renderer.isValid():
Expand Down Expand Up @@ -117,8 +120,31 @@ def __init__(
if signal_stop_spinning is not None:
self.set_enable_signal(signal_stop_spinning)

def _is_deleted(self) -> bool:
try:
return sip.isdeleted(self)
except RuntimeError:
return True

def _cleanup_before_deletion(self) -> None:
self._spinning = False

if self._stop_signal:
self._disconnect_signal(self._stop_signal, self.enable_button)
self._stop_signal = None

for timer in (self.timer, self.timeout_timer):
try:
if timer.isActive():
timer.stop()
except RuntimeError:
continue

def start_spin(self) -> None:
"""Idempotent: safe to call multiple times."""
if self._is_deleted():
return

if self._spinning:
if not self.timer.isActive():
self.timer.start()
Expand Down Expand Up @@ -147,6 +173,9 @@ def start_spin(self) -> None:

def enable_button(self, *args, **kwargs) -> None:
"""Idempotent: safe to call multiple times."""
if self._is_deleted():
return

if self.timer.isActive():
self.timer.stop()
if self.timeout_timer.isActive():
Expand All @@ -155,15 +184,24 @@ def enable_button(self, *args, **kwargs) -> None:
was_spinning = self._spinning
self._spinning = False

self.setIcon(self.enabled_icon)
try:
self.setIcon(self.enabled_icon)
except RuntimeError:
return

# If we disabled during spinning, re-enable
if self.disable_while_spinning and not self.isEnabled():
self.setEnabled(True)
try:
if self.disable_while_spinning and not self.isEnabled():
self.setEnabled(True)
except RuntimeError:
return

if was_spinning:
self.signal_stopped_spinning.emit()
self.update()
try:
self.signal_stopped_spinning.emit()
self.update()
except RuntimeError:
return

def _disconnect_signal(self, signal: SignalProtocol, slot) -> None:
"""
Expand Down Expand Up @@ -196,18 +234,33 @@ def set_enable_signal(self, signal_stop_spinning: SignalProtocol | None) -> None
self._stop_signal = None

def on_clicked(self) -> None:
if not self.isEnabled():
if self._is_deleted() or not self.isEnabled():
return
self.start_spin()

def rotate_svg(self) -> None:
if self._is_deleted():
return

if not self._spinning:
if self.timer.isActive():
self.timer.stop()
return

self.rotation_angle = (self.rotation_angle + 10) % 360
self.setIcon(self._spinner_icon(self.rotation_angle))
try:
self.setIcon(self._spinner_icon(self.rotation_angle))
except RuntimeError:
self._cleanup_before_deletion()

def closeEvent(self, event: QCloseEvent | None) -> None:
self._cleanup_before_deletion()
super().closeEvent(event)

def event(self, event: QEvent | None) -> bool:
if event and event.type() == QEvent.Type.DeferredDelete:
self._cleanup_before_deletion()
return super().event(event)

def _spinner_icon(self, angle: float) -> QIcon:
size = self.iconSize()
Expand Down
57 changes: 57 additions & 0 deletions tests/test_spinning_button.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from types import SimpleNamespace
from typing import Any, cast

from bitcoin_safe_lib.gui.qt.spinning_button import SpinningButton


class TimerStub:
def __init__(self, active: bool) -> None:
self.active = active

def isActive(self) -> bool:
return self.active

def stop(self) -> None:
self.active = False


def test_cleanup_before_deletion_stops_timers_and_disconnects_stop_signal() -> None:
disconnected: list[tuple[object, object]] = []
stop_signal = object()
enable_button = object()
button = SimpleNamespace(
_spinning=True,
_stop_signal=stop_signal,
timer=TimerStub(active=True),
timeout_timer=TimerStub(active=True),
enable_button=enable_button,
_disconnect_signal=lambda signal, slot: disconnected.append((signal, slot)),
)

SpinningButton._cleanup_before_deletion(cast(Any, button))

assert button._spinning is False
assert button._stop_signal is None
assert disconnected == [(stop_signal, enable_button)]
assert button.timer.isActive() is False
assert button.timeout_timer.isActive() is False


def test_rotate_svg_returns_immediately_for_deleted_wrapper() -> None:
button = SimpleNamespace(
_is_deleted=lambda: True,
_spinning=True,
timer=TimerStub(active=True),
rotation_angle=0,
)

SpinningButton.rotate_svg(cast(Any, button))

assert button.rotation_angle == 0
assert button.timer.isActive() is True


def test_enable_button_returns_immediately_for_deleted_wrapper() -> None:
button = SimpleNamespace(_is_deleted=lambda: True)

SpinningButton.enable_button(cast(Any, button))
Loading