v0.1.6: highlight the folder a dragged playlist would drop into
This commit is contained in:
18
TASKS.md
18
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**.
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||
|
||||
__version__ = "0.1.5"
|
||||
__version__ = "0.1.6"
|
||||
|
||||
@ -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
135
tests/test_round25.py
Normal 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
|
||||
Reference in New Issue
Block a user