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. 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 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) ## Round 24 — sidebar drag auto-scroll (v0.1.5)
Tests in `tests/test_round24.py`. Fix round → patch bump **0.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.""" """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) super().__init__(parent)
self._manager = manager self._manager = manager
self._drop_target: QTreeWidgetItem | None = None 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.setHeaderHidden(True)
self.setIndentation(14) self.setIndentation(14)
@ -396,13 +399,14 @@ class PlaylistTree(QTreeWidget):
# so edge auto-scroll stays live even over non-folder rows — # so edge auto-scroll stays live even over non-folder rows —
# otherwise scrolling down to a distant folder would be nearly # otherwise scrolling down to a distant folder would be nearly
# impossible past the playlists in between. # impossible past the playlists in between.
self._clear_drop_target() self._drag_target_kind = "folder"
target = self.itemAt(pos) 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._highlight_drop_target(pos, "folder")
self._update_autoscroll(pos, accepted=True) self._update_autoscroll(pos, accepted=True)
return return
if not event.mimeData().hasFormat(TRACKS_MIME): if not event.mimeData().hasFormat(TRACKS_MIME):
@ -410,23 +414,48 @@ class PlaylistTree(QTreeWidget):
self._update_autoscroll(pos, accepted=False) self._update_autoscroll(pos, accepted=False)
event.ignore() event.ignore()
return return
self._drag_target_kind = "playlist"
target = self.itemAt(pos) 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)
event.acceptProposedAction() event.acceptProposedAction()
self._update_autoscroll(pos, accepted=True) self._update_autoscroll(pos, accepted=True)
else: else:
self._clear_drop_target()
event.ignore() event.ignore()
self._update_autoscroll(pos, accepted=False) self._update_autoscroll(pos, accepted=False)
self._highlight_drop_target(pos, "playlist")
def dragLeaveEvent(self, event): def dragLeaveEvent(self, event):
self._clear_drop_target() self._clear_drop_target()
self._stop_autoscroll() self._stop_autoscroll()
super().dragLeaveEvent(event) 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): 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.""" out). Foreground is set too so the name stays legible on the highlight."""
if item is self._drop_target: if item is self._drop_target:
return return
@ -463,18 +492,16 @@ class PlaylistTree(QTreeWidget):
self._autoscroll_timer.stop() self._autoscroll_timer.stop()
return return
# The cursor may be held still while rows scroll under it, so keep the # 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; # drop-target highlight on the row now beneath it.
# internal playlist moves never set a drop target). if (self._autoscroll_pos is not None
if self._autoscroll_pos is not None and self._drop_target is not None: and self._drag_target_kind is not None):
target = self.itemAt(self._autoscroll_pos) self._highlight_drop_target(self._autoscroll_pos,
if target and target.data(0, KIND_ROLE) == "playlist": self._drag_target_kind)
self._set_drop_target(target)
else:
self._clear_drop_target()
def _stop_autoscroll(self): def _stop_autoscroll(self):
self._autoscroll_dir = 0 self._autoscroll_dir = 0
self._autoscroll_pos = None self._autoscroll_pos = None
self._drag_target_kind = None
self._autoscroll_timer.stop() self._autoscroll_timer.stop()
def dropEvent(self, event): 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