From d6639710fd7c209f468bb684ab66c66f3541fede Mon Sep 17 00:00:00 2001 From: trav Date: Fri, 17 Jul 2026 13:23:51 -0400 Subject: [PATCH] v0.1.6: highlight the folder a dragged playlist would drop into --- TASKS.md | 18 ++++++ lintunes/__init__.py | 2 +- lintunes/gui/sidebar.py | 51 +++++++++++---- tests/test_round25.py | 135 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 193 insertions(+), 13 deletions(-) create mode 100644 tests/test_round25.py diff --git a/TASKS.md b/TASKS.md index dc2bfb2..d552718 100644 --- a/TASKS.md +++ b/TASKS.md @@ -3,6 +3,24 @@ Legend: `[ ]` todo · `[~]` in progress · `[x]` done. When a round closes, move its finished items to `tasks-done.md`. +## Round 25 — folder highlight while dragging a playlist (v0.1.6) + +Tests in `tests/test_round25.py`. Fix round → patch bump **0.1.6**. + +Dragging a playlist over a folder gave no indication the drop would land +inside it. `PlaylistTree` now highlights the hovered folder mid-drag with the +same treatment track drops give playlist rows, via a shared +`_highlight_drop_target(pos, kind)` helper ("folder" for internal moves, +"playlist" for track drops). A folder in the dragged row's own branch is +never highlighted — `move_playlist` rejects that drop as a cycle, so lighting +it up would lie. The Round 24 autoscroll tick's highlight refresh became +kind-aware (`_drag_target_kind`) so a folder highlight survives edge +auto-scrolling instead of being cleared by the track-only check. + +- [x] `lintunes/gui/sidebar.py` — `_highlight_drop_target` / + `_is_own_branch` helpers; `_drag_target_kind` tracked through + `dragMoveEvent`, reset in `_stop_autoscroll`. + ## Round 24 — sidebar drag auto-scroll (v0.1.5) Tests in `tests/test_round24.py`. Fix round → patch bump **0.1.5**. diff --git a/lintunes/__init__.py b/lintunes/__init__.py index d60206d..a7ab430 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.5" +__version__ = "0.1.6" diff --git a/lintunes/gui/sidebar.py b/lintunes/gui/sidebar.py index ce03393..19c52c4 100644 --- a/lintunes/gui/sidebar.py +++ b/lintunes/gui/sidebar.py @@ -157,6 +157,9 @@ class PlaylistTree(QTreeWidget): super().__init__(parent) self._manager = manager self._drop_target: QTreeWidgetItem | None = None + # KIND_ROLE value a drop during the active drag would land on + # ("folder" for internal playlist moves, "playlist" for track drops). + self._drag_target_kind: str | None = None self.setHeaderHidden(True) self.setIndentation(14) @@ -396,13 +399,14 @@ class PlaylistTree(QTreeWidget): # 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._drag_target_kind = "folder" 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._highlight_drop_target(pos, "folder") self._update_autoscroll(pos, accepted=True) return if not event.mimeData().hasFormat(TRACKS_MIME): @@ -410,23 +414,48 @@ class PlaylistTree(QTreeWidget): self._update_autoscroll(pos, accepted=False) event.ignore() return + self._drag_target_kind = "playlist" 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) + self._highlight_drop_target(pos, "playlist") def dragLeaveEvent(self, event): self._clear_drop_target() self._stop_autoscroll() super().dragLeaveEvent(event) + def _highlight_drop_target(self, pos: QPoint, kind: str): + """Highlight the row at ``pos`` if it's a valid target for the active + drag — a folder for internal playlist moves, a playlist for track + drops — so the user can see where the drop would land. Clears any + stale highlight otherwise. A folder inside the dragged row's own + branch is never highlighted: the manager would reject that drop as a + cycle, so lighting it up would lie.""" + target = self.itemAt(pos) + ok = target is not None and target.data(0, KIND_ROLE) == kind + if ok and kind == "folder": + ok = not self._is_own_branch(self.currentItem(), target) + if ok: + self._set_drop_target(target) + else: + self._clear_drop_target() + + @staticmethod + def _is_own_branch(dragged, item) -> bool: + """True if ``item`` is the dragged row or inside its subtree.""" + while item is not None: + if item is dragged: + return True + item = item.parent() + return False + def _set_drop_target(self, item): - """Highlight the playlist row a drop would land on (cleared on the way + """Highlight the row a drop would land on (cleared on the way out). Foreground is set too so the name stays legible on the highlight.""" if item is self._drop_target: return @@ -463,18 +492,16 @@ class PlaylistTree(QTreeWidget): 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() + # drop-target highlight on the row now beneath it. + if (self._autoscroll_pos is not None + and self._drag_target_kind is not None): + self._highlight_drop_target(self._autoscroll_pos, + self._drag_target_kind) def _stop_autoscroll(self): self._autoscroll_dir = 0 self._autoscroll_pos = None + self._drag_target_kind = None self._autoscroll_timer.stop() def dropEvent(self, event): diff --git a/tests/test_round25.py b/tests/test_round25.py new file mode 100644 index 0000000..2e77206 --- /dev/null +++ b/tests/test_round25.py @@ -0,0 +1,135 @@ +"""Round 25: folder highlight while dragging a playlist. + +Hovering a folder mid-drag lights it up so the user can see the drop would +land inside it (previously there was no indication at all). A folder in the +dragged row's own branch is never highlighted — the manager rejects that drop +as a cycle, so lighting it up would lie. The autoscroll tick's highlight +refresh is now kind-aware so a folder highlight survives edge auto-scrolling. + +Synthetic QDropEvents report source() == None, so the internal-move branch of +dragMoveEvent can't be driven directly; _highlight_drop_target carries the +logic and is what gets tested. +""" + +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, height=400): + library = Library(tracks={1: Track(track_id=1, name="One")}) + manager = LibraryManager(library, tmp_path) + tree = PlaylistTree(manager) + tree.resize(200, height) + tree.show() + return manager, tree + + +def _pos_over(tree, item) -> QPoint: + return tree.visualItemRect(item).center() + + +class TestFolderHighlight: + def test_folder_highlighted_while_dragging_playlist(self, qapp, tmp_path): + manager, tree = _tree(qapp, tmp_path) + folder = manager.create_folder("a folder") + playlist = manager.create_playlist("a playlist") + folder_item = tree._find_item(folder.persistent_id) + tree.setCurrentItem(tree._find_item(playlist.persistent_id)) + + tree._highlight_drop_target(_pos_over(tree, folder_item), "folder") + assert tree._drop_target is folder_item + + def test_leaving_the_folder_clears_the_highlight(self, qapp, tmp_path): + manager, tree = _tree(qapp, tmp_path) + folder = manager.create_folder("a folder") + playlist = manager.create_playlist("a playlist") + folder_item = tree._find_item(folder.persistent_id) + tree.setCurrentItem(tree._find_item(playlist.persistent_id)) + + tree._highlight_drop_target(_pos_over(tree, folder_item), "folder") + assert tree._drop_target is not None + # Hover empty space below the rows (drop there = top level). + tree._highlight_drop_target( + QPoint(10, tree.viewport().height() - 2), "folder") + assert tree._drop_target is None + + def test_playlist_row_is_not_a_folder_target(self, qapp, tmp_path): + manager, tree = _tree(qapp, tmp_path) + playlist = manager.create_playlist("a playlist") + other = manager.create_playlist("another playlist") + tree.setCurrentItem(tree._find_item(other.persistent_id)) + + tree._highlight_drop_target( + _pos_over(tree, tree._find_item(playlist.persistent_id)), "folder") + assert tree._drop_target is None + + def test_dragged_folder_cannot_drop_into_its_own_branch( + self, qapp, tmp_path): + manager, tree = _tree(qapp, tmp_path) + outer = manager.create_folder("a outer") + inner = manager.create_folder("b inner", outer.persistent_id) + other = manager.create_folder("c other") + outer_item = tree._find_item(outer.persistent_id) + inner_item = tree._find_item(inner.persistent_id) + other_item = tree._find_item(other.persistent_id) + outer_item.setExpanded(True) + tree.setCurrentItem(outer_item) # dragging "outer" + + # Onto itself: no highlight (manager would no-op the drop). + tree._highlight_drop_target(_pos_over(tree, outer_item), "folder") + assert tree._drop_target is None + # Into its own subfolder: no highlight (manager rejects the cycle). + tree._highlight_drop_target(_pos_over(tree, inner_item), "folder") + assert tree._drop_target is None + # Into an unrelated folder: highlighted. + tree._highlight_drop_target(_pos_over(tree, other_item), "folder") + assert tree._drop_target is other_item + + def test_track_drag_still_highlights_playlists(self, qapp, tmp_path): + manager, tree = _tree(qapp, tmp_path) + folder = manager.create_folder("a folder") + playlist = manager.create_playlist("a playlist") + folder_item = tree._find_item(folder.persistent_id) + playlist_item = tree._find_item(playlist.persistent_id) + + tree._highlight_drop_target(_pos_over(tree, playlist_item), "playlist") + assert tree._drop_target is playlist_item + # Folders are not track-drop targets. + tree._highlight_drop_target(_pos_over(tree, folder_item), "playlist") + assert tree._drop_target is None + + +class TestKindAwareTick: + def test_tick_clears_folder_highlight_scrolled_out_from_under_cursor( + self, qapp, tmp_path): + manager, tree = _tree(qapp, tmp_path, height=100) + folder = manager.create_folder("aaa folder") # sorts to the top row + dragged = None + for i in range(60): + dragged = manager.create_playlist(f"playlist {i}") + tree.setCurrentItem(tree._find_item(dragged.persistent_id)) + tree.scrollToTop() # setCurrentItem scrolled the dragged row into view + + pos = _pos_over(tree, tree._find_item(folder.persistent_id)) + tree._highlight_drop_target(pos, "folder") + assert tree._drop_target is not None + + # Cursor held still over that spot while the edge auto-scroll rolls: + # the folder scrolls away and a non-target row takes its place. + tree._drag_target_kind = "folder" + tree._autoscroll_pos = pos + tree._autoscroll_dir = 1 + assert tree.verticalScrollBar().value() == 0 + tree._autoscroll_tick() + + assert tree.verticalScrollBar().value() == 1 + assert tree._drop_target is None + + def test_stop_autoscroll_resets_drag_target_kind(self, qapp, tmp_path): + _manager, tree = _tree(qapp, tmp_path) + tree._drag_target_kind = "folder" + tree._stop_autoscroll() + assert tree._drag_target_kind is None