v0.1.5: sidebar playlist tree auto-scrolls when dragging near its edges
This commit is contained in:
19
TASKS.md
19
TASKS.md
@ -3,6 +3,25 @@
|
|||||||
Legend: `[ ]` todo · `[~]` in progress · `[x]` done.
|
Legend: `[ ]` todo · `[~]` in progress · `[x]` done.
|
||||||
When a round closes, move its finished items to `tasks-done.md`.
|
When a round closes, move its finished items to `tasks-done.md`.
|
||||||
|
|
||||||
|
## Round 24 — sidebar drag auto-scroll (v0.1.5)
|
||||||
|
|
||||||
|
Tests in `tests/test_round24.py`. Fix round → patch bump **0.1.5**.
|
||||||
|
|
||||||
|
Dragging a playlist near the top/bottom edge of the sidebar tree scrolled
|
||||||
|
nothing: `PlaylistTree.dragMoveEvent` fully overrides the base implementation,
|
||||||
|
so Qt's built-in autoscroll (in `QAbstractItemView.dragMoveEvent`) never ran —
|
||||||
|
the same root cause Round 13 fixed in the track table. Ported that machinery
|
||||||
|
(`autoscroll_direction` shared from `track_table`, 40 ms timer, one unit per
|
||||||
|
tick) into `PlaylistTree`. Playlist moves keep the scroll live at the edge
|
||||||
|
even over non-folder rows (the tree always accepts an internal move
|
||||||
|
somewhere); track drags gate on hovering a real playlist row, matching the
|
||||||
|
drop-target highlight, and the highlight re-tracks the row under a held-still
|
||||||
|
cursor as it scrolls.
|
||||||
|
|
||||||
|
- [x] `lintunes/gui/sidebar.py` — `_update_autoscroll` / `_autoscroll_tick` /
|
||||||
|
`_stop_autoscroll` wired into `dragMoveEvent`, `dragLeaveEvent`,
|
||||||
|
`dropEvent`.
|
||||||
|
|
||||||
## Round 23 — playback-control provenance log (v0.1.4)
|
## Round 23 — playback-control provenance log (v0.1.4)
|
||||||
|
|
||||||
Tests in `tests/test_round23.py`. Diagnostic round → patch bump **0.1.4**.
|
Tests in `tests/test_round23.py`. Diagnostic round → patch bump **0.1.4**.
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||||
|
|
||||||
__version__ = "0.1.4"
|
__version__ = "0.1.5"
|
||||||
|
|||||||
@ -2,12 +2,14 @@ from PyQt6.QtWidgets import (
|
|||||||
QWidget, QVBoxLayout, QPushButton, QTreeWidget, QTreeWidgetItem, QMenu,
|
QWidget, QVBoxLayout, QPushButton, QTreeWidget, QTreeWidgetItem, QMenu,
|
||||||
QInputDialog, QMessageBox, QAbstractItemView,
|
QInputDialog, QMessageBox, QAbstractItemView,
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import Qt, QTimer, pyqtSignal
|
from PyQt6.QtCore import Qt, QTimer, QPoint, pyqtSignal
|
||||||
from PyQt6.QtGui import QBrush, QPainter, QPixmap
|
from PyQt6.QtGui import QBrush, QPainter, QPixmap
|
||||||
|
|
||||||
from lintunes.models import PlaylistType
|
from lintunes.models import PlaylistType
|
||||||
from lintunes import tagging
|
from lintunes import tagging
|
||||||
from lintunes.gui.track_table import parse_tracks_mime, TRACKS_MIME
|
from lintunes.gui.track_table import (
|
||||||
|
parse_tracks_mime, TRACKS_MIME, autoscroll_direction,
|
||||||
|
)
|
||||||
from lintunes.gui.playlist_ops import add_tracks_with_dup_check
|
from lintunes.gui.playlist_ops import add_tracks_with_dup_check
|
||||||
from lintunes.gui.art_window import ArtWindow
|
from lintunes.gui.art_window import ArtWindow
|
||||||
from lintunes.gui import drag_ghost
|
from lintunes.gui import drag_ghost
|
||||||
@ -166,6 +168,15 @@ class PlaylistTree(QTreeWidget):
|
|||||||
self.currentItemChanged.connect(self._on_selection_changed)
|
self.currentItemChanged.connect(self._on_selection_changed)
|
||||||
self.itemChanged.connect(self._on_item_renamed)
|
self.itemChanged.connect(self._on_item_renamed)
|
||||||
|
|
||||||
|
# Edge auto-scroll during a drag (Qt's built-in autoscroll never runs
|
||||||
|
# because our dragMoveEvent accepts the event itself). Mirrors
|
||||||
|
# track_table's Round 13 machinery.
|
||||||
|
self._autoscroll_dir = 0
|
||||||
|
self._autoscroll_pos: QPoint | None = None
|
||||||
|
self._autoscroll_timer = QTimer(self)
|
||||||
|
self._autoscroll_timer.setInterval(40)
|
||||||
|
self._autoscroll_timer.timeout.connect(self._autoscroll_tick)
|
||||||
|
|
||||||
manager.playlists_changed.connect(self.rebuild)
|
manager.playlists_changed.connect(self.rebuild)
|
||||||
self.rebuild()
|
self.rebuild()
|
||||||
|
|
||||||
@ -377,31 +388,41 @@ class PlaylistTree(QTreeWidget):
|
|||||||
event.ignore()
|
event.ignore()
|
||||||
|
|
||||||
def dragMoveEvent(self, event):
|
def dragMoveEvent(self, event):
|
||||||
drag_ghost.move(self.viewport().mapToGlobal(event.position().toPoint()))
|
pos = event.position().toPoint()
|
||||||
|
drag_ghost.move(self.viewport().mapToGlobal(pos))
|
||||||
if event.source() is self:
|
if event.source() is self:
|
||||||
# Moving a playlist into/out of folders
|
# Moving a playlist into/out of folders. The tree accepts an
|
||||||
|
# internal move somewhere no matter what (empty area = top level),
|
||||||
|
# so edge auto-scroll stays live even over non-folder rows —
|
||||||
|
# otherwise scrolling down to a distant folder would be nearly
|
||||||
|
# impossible past the playlists in between.
|
||||||
self._clear_drop_target()
|
self._clear_drop_target()
|
||||||
target = self.itemAt(event.position().toPoint())
|
target = self.itemAt(pos)
|
||||||
kind = target.data(0, KIND_ROLE) if target else ""
|
kind = target.data(0, KIND_ROLE) if target else ""
|
||||||
if kind in ("folder", "") or target is None:
|
if kind in ("folder", "") or target is None:
|
||||||
event.acceptProposedAction()
|
event.acceptProposedAction()
|
||||||
else:
|
else:
|
||||||
event.ignore()
|
event.ignore()
|
||||||
|
self._update_autoscroll(pos, accepted=True)
|
||||||
return
|
return
|
||||||
if not event.mimeData().hasFormat(TRACKS_MIME):
|
if not event.mimeData().hasFormat(TRACKS_MIME):
|
||||||
self._clear_drop_target()
|
self._clear_drop_target()
|
||||||
|
self._update_autoscroll(pos, accepted=False)
|
||||||
event.ignore()
|
event.ignore()
|
||||||
return
|
return
|
||||||
target = self.itemAt(event.position().toPoint())
|
target = self.itemAt(pos)
|
||||||
if target and target.data(0, KIND_ROLE) == "playlist":
|
if target and target.data(0, KIND_ROLE) == "playlist":
|
||||||
self._set_drop_target(target)
|
self._set_drop_target(target)
|
||||||
event.acceptProposedAction()
|
event.acceptProposedAction()
|
||||||
|
self._update_autoscroll(pos, accepted=True)
|
||||||
else:
|
else:
|
||||||
self._clear_drop_target()
|
self._clear_drop_target()
|
||||||
event.ignore()
|
event.ignore()
|
||||||
|
self._update_autoscroll(pos, accepted=False)
|
||||||
|
|
||||||
def dragLeaveEvent(self, event):
|
def dragLeaveEvent(self, event):
|
||||||
self._clear_drop_target()
|
self._clear_drop_target()
|
||||||
|
self._stop_autoscroll()
|
||||||
super().dragLeaveEvent(event)
|
super().dragLeaveEvent(event)
|
||||||
|
|
||||||
def _set_drop_target(self, item):
|
def _set_drop_target(self, item):
|
||||||
@ -422,8 +443,43 @@ class PlaylistTree(QTreeWidget):
|
|||||||
self._drop_target.setForeground(0, self._drop_target_fg)
|
self._drop_target.setForeground(0, self._drop_target_fg)
|
||||||
self._drop_target = None
|
self._drop_target = None
|
||||||
|
|
||||||
|
# ---- drag auto-scroll ----
|
||||||
|
|
||||||
|
def _update_autoscroll(self, pos: QPoint, accepted: bool):
|
||||||
|
"""Start/stop edge auto-scroll based on the drag's position."""
|
||||||
|
self._autoscroll_pos = pos
|
||||||
|
self._autoscroll_dir = autoscroll_direction(
|
||||||
|
pos.y(), self.viewport().height(), accepted)
|
||||||
|
if self._autoscroll_dir and not self._autoscroll_timer.isActive():
|
||||||
|
self._autoscroll_timer.start()
|
||||||
|
elif not self._autoscroll_dir and self._autoscroll_timer.isActive():
|
||||||
|
self._autoscroll_timer.stop()
|
||||||
|
|
||||||
|
def _autoscroll_tick(self):
|
||||||
|
bar = self.verticalScrollBar()
|
||||||
|
before = bar.value()
|
||||||
|
bar.setValue(before + self._autoscroll_dir)
|
||||||
|
if bar.value() == before: # reached the top/bottom — nothing more to do
|
||||||
|
self._autoscroll_timer.stop()
|
||||||
|
return
|
||||||
|
# The cursor may be held still while rows scroll under it, so keep the
|
||||||
|
# drop-target highlight on the row now beneath it (track drags only;
|
||||||
|
# internal playlist moves never set a drop target).
|
||||||
|
if self._autoscroll_pos is not None and self._drop_target is not None:
|
||||||
|
target = self.itemAt(self._autoscroll_pos)
|
||||||
|
if target and target.data(0, KIND_ROLE) == "playlist":
|
||||||
|
self._set_drop_target(target)
|
||||||
|
else:
|
||||||
|
self._clear_drop_target()
|
||||||
|
|
||||||
|
def _stop_autoscroll(self):
|
||||||
|
self._autoscroll_dir = 0
|
||||||
|
self._autoscroll_pos = None
|
||||||
|
self._autoscroll_timer.stop()
|
||||||
|
|
||||||
def dropEvent(self, event):
|
def dropEvent(self, event):
|
||||||
self._clear_drop_target()
|
self._clear_drop_target()
|
||||||
|
self._stop_autoscroll()
|
||||||
target = self.itemAt(event.position().toPoint())
|
target = self.itemAt(event.position().toPoint())
|
||||||
if event.source() is self:
|
if event.source() is self:
|
||||||
item = self.currentItem()
|
item = self.currentItem()
|
||||||
|
|||||||
82
tests/test_round24.py
Normal file
82
tests/test_round24.py
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
"""Round 24: sidebar drag auto-scroll.
|
||||||
|
|
||||||
|
Dragging a playlist (or tracks) near the top/bottom edge of the sidebar's
|
||||||
|
playlist tree scrolls the tree so off-screen rows can be reached. Same root
|
||||||
|
cause and fix as the track table's Round 13: our dragMoveEvent accepts the
|
||||||
|
event itself, so Qt's built-in autoscroll never runs. The direction helper
|
||||||
|
(autoscroll_direction) is shared with the table and already covered by
|
||||||
|
test_round13.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PyQt6.QtCore import QPoint
|
||||||
|
|
||||||
|
from lintunes.models import Library, Track
|
||||||
|
from lintunes.library_manager import LibraryManager
|
||||||
|
from lintunes.gui.sidebar import PlaylistTree
|
||||||
|
|
||||||
|
|
||||||
|
def _tree(qapp, tmp_path, playlists=0):
|
||||||
|
library = Library(tracks={1: Track(track_id=1, name="One")})
|
||||||
|
manager = LibraryManager(library, tmp_path)
|
||||||
|
tree = PlaylistTree(manager)
|
||||||
|
for i in range(playlists):
|
||||||
|
manager.create_playlist(f"playlist {i}")
|
||||||
|
# Small viewport: enough rows that the scrollbar gets a real range (the
|
||||||
|
# view re-derives the range itself, so a manual setRange doesn't stick;
|
||||||
|
# show() forces the polish/layout pass that computes it).
|
||||||
|
tree.resize(200, 100)
|
||||||
|
tree.show()
|
||||||
|
return tree
|
||||||
|
|
||||||
|
|
||||||
|
class TestAutoscrollTick:
|
||||||
|
def test_tick_scrolls_by_direction(self, qapp, tmp_path):
|
||||||
|
tree = _tree(qapp, tmp_path, playlists=60)
|
||||||
|
bar = tree.verticalScrollBar()
|
||||||
|
assert bar.maximum() > 20 # real range from the rows
|
||||||
|
bar.setValue(10)
|
||||||
|
|
||||||
|
tree._autoscroll_dir = 1
|
||||||
|
tree._autoscroll_tick()
|
||||||
|
assert bar.value() == 11
|
||||||
|
|
||||||
|
tree._autoscroll_dir = -1
|
||||||
|
tree._autoscroll_tick()
|
||||||
|
assert bar.value() == 10
|
||||||
|
|
||||||
|
def test_tick_stops_at_the_end(self, qapp, tmp_path):
|
||||||
|
tree = _tree(qapp, tmp_path, playlists=60)
|
||||||
|
bar = tree.verticalScrollBar()
|
||||||
|
bar.setValue(bar.maximum()) # already at the bottom
|
||||||
|
|
||||||
|
tree._autoscroll_dir = 1
|
||||||
|
tree._autoscroll_timer.start()
|
||||||
|
tree._autoscroll_tick()
|
||||||
|
|
||||||
|
assert bar.value() == bar.maximum()
|
||||||
|
assert not tree._autoscroll_timer.isActive()
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateAutoscroll:
|
||||||
|
def test_edge_starts_timer(self, qapp, tmp_path):
|
||||||
|
tree = _tree(qapp, tmp_path)
|
||||||
|
tree.resize(200, 300)
|
||||||
|
tree._update_autoscroll(QPoint(10, 5), accepted=True)
|
||||||
|
assert tree._autoscroll_dir == -1
|
||||||
|
assert tree._autoscroll_timer.isActive()
|
||||||
|
|
||||||
|
def test_rejected_drop_keeps_timer_off(self, qapp, tmp_path):
|
||||||
|
tree = _tree(qapp, tmp_path)
|
||||||
|
tree.resize(200, 300)
|
||||||
|
tree._update_autoscroll(QPoint(10, 5), accepted=False)
|
||||||
|
assert tree._autoscroll_dir == 0
|
||||||
|
assert not tree._autoscroll_timer.isActive()
|
||||||
|
|
||||||
|
def test_stop_autoscroll_resets(self, qapp, tmp_path):
|
||||||
|
tree = _tree(qapp, tmp_path)
|
||||||
|
tree._autoscroll_dir = 1
|
||||||
|
tree._autoscroll_timer.start()
|
||||||
|
tree._stop_autoscroll()
|
||||||
|
assert tree._autoscroll_dir == 0
|
||||||
|
assert tree._autoscroll_pos is None
|
||||||
|
assert not tree._autoscroll_timer.isActive()
|
||||||
Reference in New Issue
Block a user