v0.1.6: highlight the folder a dragged playlist would drop into

This commit is contained in:
2026-07-17 13:23:51 -04:00
parent 2820e6df8a
commit d6639710fd
4 changed files with 193 additions and 13 deletions

View File

@ -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**.

View File

@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.1.5"
__version__ = "0.1.6"

View File

@ -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):

135
tests/test_round25.py Normal file
View File

@ -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