diff --git a/TASKS.md b/TASKS.md index 017fc96..dc2bfb2 100644 --- a/TASKS.md +++ b/TASKS.md @@ -3,6 +3,25 @@ Legend: `[ ]` todo · `[~]` in progress · `[x]` done. 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) Tests in `tests/test_round23.py`. Diagnostic round → patch bump **0.1.4**. diff --git a/lintunes/__init__.py b/lintunes/__init__.py index d8f892c..d60206d 100644 --- a/lintunes/__init__.py +++ b/lintunes/__init__.py @@ -1,3 +1,3 @@ """LinTunes — iTunes-style music library manager and player for Linux.""" -__version__ = "0.1.4" +__version__ = "0.1.5" diff --git a/lintunes/gui/sidebar.py b/lintunes/gui/sidebar.py index 19d4dca..ce03393 100644 --- a/lintunes/gui/sidebar.py +++ b/lintunes/gui/sidebar.py @@ -2,12 +2,14 @@ from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QPushButton, QTreeWidget, QTreeWidgetItem, QMenu, 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 lintunes.models import PlaylistType 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.art_window import ArtWindow from lintunes.gui import drag_ghost @@ -166,6 +168,15 @@ class PlaylistTree(QTreeWidget): self.currentItemChanged.connect(self._on_selection_changed) 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) self.rebuild() @@ -377,31 +388,41 @@ class PlaylistTree(QTreeWidget): event.ignore() 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: - # 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() - target = self.itemAt(event.position().toPoint()) + target = self.itemAt(pos) kind = target.data(0, KIND_ROLE) if target else "" if kind in ("folder", "") or target is None: event.acceptProposedAction() else: event.ignore() + self._update_autoscroll(pos, accepted=True) return if not event.mimeData().hasFormat(TRACKS_MIME): self._clear_drop_target() + self._update_autoscroll(pos, accepted=False) event.ignore() return - target = self.itemAt(event.position().toPoint()) + target = self.itemAt(pos) if target and target.data(0, KIND_ROLE) == "playlist": self._set_drop_target(target) event.acceptProposedAction() + self._update_autoscroll(pos, accepted=True) else: self._clear_drop_target() event.ignore() + self._update_autoscroll(pos, accepted=False) def dragLeaveEvent(self, event): self._clear_drop_target() + self._stop_autoscroll() super().dragLeaveEvent(event) 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 = 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): self._clear_drop_target() + self._stop_autoscroll() target = self.itemAt(event.position().toPoint()) if event.source() is self: item = self.currentItem() diff --git a/tests/test_round24.py b/tests/test_round24.py new file mode 100644 index 0000000..0527a56 --- /dev/null +++ b/tests/test_round24.py @@ -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()