195 lines
7.6 KiB
Python
195 lines
7.6 KiB
Python
"""Hover Event Manager with Debounce Logic.
|
|
|
|
This module provides a robust hover event management system for the track list,
|
|
implementing debouncing to prevent UI flickering and excessive updates during
|
|
rapid mouse movements.
|
|
|
|
The HoverManager intercepts mouse movement events and triggers callbacks only
|
|
after the mouse has remained stationary over an item for a configurable delay.
|
|
|
|
Architecture:
|
|
- HoverManager: Main coordinator with debounce timer
|
|
- Event filter for capturing mouse movements
|
|
- Signal-based callbacks for decoupled communication
|
|
|
|
Performance:
|
|
- Configurable debounce delay (default: 350ms)
|
|
- Automatic cleanup of pending timers
|
|
- Thread-safe signal emissions
|
|
"""
|
|
|
|
from contextlib import suppress
|
|
|
|
from PySide6 import QtCore, QtGui, QtWidgets
|
|
from shiboken6 import Shiboken
|
|
from tidalapi import Album, Mix, Playlist, Track, Video
|
|
from tidalapi.artist import Artist
|
|
|
|
from tidal_dl_ng.helper.gui import get_results_media_item
|
|
from tidal_dl_ng.logger import logger_gui
|
|
|
|
|
|
class HoverManager(QtCore.QObject):
|
|
"""Manages hover events with debouncing to prevent UI flickering.
|
|
|
|
This class implements an event filter that intercepts mouse movements over
|
|
a QTreeView and emits signals after a configurable delay to prevent rapid
|
|
updates when the user is just scanning through the list.
|
|
|
|
Signals:
|
|
s_hover_confirmed (object): Emitted when hover is confirmed after debounce delay.
|
|
s_hover_left: Emitted when mouse leaves the tracked widget.
|
|
|
|
Attributes:
|
|
debounce_delay_ms (int): Delay in milliseconds before confirming hover.
|
|
tree_view (QTreeView): The tree view to monitor.
|
|
proxy_model (QSortFilterProxyModel): Proxy model for the tree view.
|
|
source_model (QStandardItemModel): Source model for the tree view.
|
|
debounce_timer (QTimer): Timer for debouncing hover events.
|
|
last_hovered_media (Track | Video | Album | None): Last media item hovered.
|
|
"""
|
|
|
|
# Signals
|
|
s_hover_confirmed: QtCore.Signal = QtCore.Signal(object) # Emits media object
|
|
s_hover_left: QtCore.Signal = QtCore.Signal() # Emits when hover leaves
|
|
|
|
def __init__(
|
|
self,
|
|
tree_view: QtWidgets.QTreeView,
|
|
proxy_model: QtCore.QSortFilterProxyModel,
|
|
source_model: QtGui.QStandardItemModel,
|
|
debounce_delay_ms: int = 350,
|
|
parent: QtCore.QObject | None = None,
|
|
) -> None:
|
|
"""Initialize the HoverManager.
|
|
|
|
Args:
|
|
tree_view (QTreeView): The tree view to monitor for hover events.
|
|
proxy_model (QSortFilterProxyModel): Proxy model wrapping the source model.
|
|
source_model (QStandardItemModel): Source model containing the data.
|
|
debounce_delay_ms (int, optional): Debounce delay in milliseconds. Defaults to 350.
|
|
parent (QObject | None, optional): Parent QObject. Defaults to None.
|
|
"""
|
|
super().__init__(parent)
|
|
|
|
self.debounce_delay_ms: int = debounce_delay_ms
|
|
self.tree_view: QtWidgets.QTreeView = tree_view
|
|
self.proxy_model: QtCore.QSortFilterProxyModel = proxy_model
|
|
self.source_model: QtGui.QStandardItemModel = source_model
|
|
self.last_hovered_media: Track | Video | Album | Mix | Playlist | Artist | None = None
|
|
|
|
# Debounce timer (single-shot)
|
|
self.debounce_timer: QtCore.QTimer = QtCore.QTimer(self)
|
|
self.debounce_timer.setSingleShot(True)
|
|
self.debounce_timer.setInterval(self.debounce_delay_ms)
|
|
self.debounce_timer.timeout.connect(self._on_debounce_timeout)
|
|
|
|
# Track hover state
|
|
self.pending_media: Track | Video | Album | Mix | Playlist | Artist | None = None
|
|
|
|
# Install event filter on viewport (where mouse events occur)
|
|
self.tree_view.viewport().installEventFilter(self)
|
|
self.tree_view.viewport().setMouseTracking(True)
|
|
self.tree_view.destroyed.connect(self.stop)
|
|
|
|
def stop(self) -> None:
|
|
"""Detach event filters and stop timers to avoid dangling references."""
|
|
viewport = None
|
|
if self._is_valid(self.tree_view):
|
|
with suppress(RuntimeError):
|
|
viewport = self.tree_view.viewport()
|
|
if self._is_valid(viewport):
|
|
viewport.removeEventFilter(self)
|
|
self.tree_view = None
|
|
|
|
if self.debounce_timer.isActive():
|
|
self.debounce_timer.stop()
|
|
self.pending_media = None
|
|
|
|
def eventFilter(self, watched: QtCore.QObject, event: QtCore.QEvent) -> bool:
|
|
"""Filter events to detect mouse movements over tree items."""
|
|
viewport = self.tree_view.viewport() if self._is_valid(self.tree_view) else None
|
|
if viewport and not self._is_valid(viewport):
|
|
viewport = None
|
|
if viewport and watched == viewport:
|
|
if event.type() == QtCore.QEvent.Type.MouseMove:
|
|
# Cast to QMouseEvent for type safety
|
|
mouse_event = QtGui.QMouseEvent(event)
|
|
self._handle_mouse_move(mouse_event)
|
|
elif event.type() == QtCore.QEvent.Type.Leave:
|
|
self._handle_mouse_leave()
|
|
|
|
# Don't consume the event - let it propagate
|
|
return False
|
|
|
|
@staticmethod
|
|
def _is_valid(widget: QtCore.QObject | None) -> bool:
|
|
"""Return True if the Qt object still owns a C++ instance."""
|
|
return widget is not None and Shiboken.isValid(widget)
|
|
|
|
def _handle_mouse_move(self, event: QtGui.QMouseEvent) -> None:
|
|
"""Handle mouse move events over the tree view.
|
|
|
|
Args:
|
|
event (QMouseEvent): The mouse move event.
|
|
"""
|
|
# Get the index under the cursor
|
|
pos = event.position().toPoint()
|
|
index = self.tree_view.indexAt(pos)
|
|
|
|
if not index.isValid():
|
|
# Mouse is not over a valid item
|
|
self._cancel_pending_hover()
|
|
return
|
|
|
|
try:
|
|
# Extract media from the index
|
|
media = get_results_media_item(index, self.proxy_model, self.source_model)
|
|
|
|
if not media:
|
|
self._cancel_pending_hover()
|
|
return
|
|
|
|
# Check if we're hovering over a different item
|
|
if media != self.pending_media:
|
|
# Cancel previous timer and start new one
|
|
self.pending_media = media
|
|
self.debounce_timer.stop()
|
|
self.debounce_timer.start()
|
|
|
|
except Exception as e:
|
|
logger_gui.debug(f"Error extracting media from hover: {e}")
|
|
self._cancel_pending_hover()
|
|
|
|
def _handle_mouse_leave(self) -> None:
|
|
"""Handle mouse leaving the tree view."""
|
|
self._cancel_pending_hover()
|
|
self.s_hover_left.emit()
|
|
|
|
def _cancel_pending_hover(self) -> None:
|
|
"""Cancel any pending hover confirmation."""
|
|
self.debounce_timer.stop()
|
|
self.pending_media = None
|
|
|
|
def _on_debounce_timeout(self) -> None:
|
|
"""Handle debounce timer timeout - hover is confirmed."""
|
|
if self.pending_media:
|
|
self.last_hovered_media = self.pending_media
|
|
self.s_hover_confirmed.emit(self.pending_media)
|
|
# logger_gui.debug(f"Hover confirmed: {getattr(self.pending_media, 'name', 'Unknown')}")
|
|
|
|
def reset(self) -> None:
|
|
"""Reset the hover manager state."""
|
|
self._cancel_pending_hover()
|
|
self.last_hovered_media = None
|
|
|
|
def set_debounce_delay(self, delay_ms: int) -> None:
|
|
"""Change the debounce delay.
|
|
|
|
Args:
|
|
delay_ms (int): New delay in milliseconds.
|
|
"""
|
|
self.debounce_delay_ms = delay_ms
|
|
self.debounce_timer.setInterval(delay_ms)
|
|
logger_gui.debug(f"Hover debounce delay set to {delay_ms}ms")
|