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