v0.21.0: click to rename
Clicking the already-selected playlist in the sidebar opens inline rename, and renaming an existing playlist starts with the cursor at the end rather than the whole name selected. New playlists still select all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.20.6"
|
__version__ = "0.21.0"
|
||||||
|
|||||||
+30
-1
@@ -1,6 +1,6 @@
|
|||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QWidget, QVBoxLayout, QPushButton, QTreeWidget, QTreeWidgetItem, QMenu,
|
QWidget, QVBoxLayout, QPushButton, QTreeWidget, QTreeWidgetItem, QMenu,
|
||||||
QInputDialog, QMessageBox, QAbstractItemView,
|
QInputDialog, QMessageBox, QAbstractItemView, QLineEdit,
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import Qt, QTimer, QPoint, pyqtSignal
|
from PyQt6.QtCore import Qt, QTimer, QPoint, pyqtSignal
|
||||||
from PyQt6.QtGui import QBrush, QPainter, QPixmap
|
from PyQt6.QtGui import QBrush, QPainter, QPixmap
|
||||||
@@ -175,6 +175,14 @@ class PlaylistTree(QTreeWidget):
|
|||||||
self.customContextMenuRequested.connect(self._show_context_menu)
|
self.customContextMenuRequested.connect(self._show_context_menu)
|
||||||
self.currentItemChanged.connect(self._on_selection_changed)
|
self.currentItemChanged.connect(self._on_selection_changed)
|
||||||
self.itemChanged.connect(self._on_item_renamed)
|
self.itemChanged.connect(self._on_item_renamed)
|
||||||
|
# Clicking the already-selected playlist renames it (Qt waits out the
|
||||||
|
# double-click interval first, and a press that becomes a drag never
|
||||||
|
# opens the editor).
|
||||||
|
self.setEditTriggers(self.editTriggers()
|
||||||
|
| QAbstractItemView.EditTrigger.SelectedClicked)
|
||||||
|
# Only a brand-new playlist opens its editor with everything selected,
|
||||||
|
# so typing replaces "untitled playlist"; see edit().
|
||||||
|
self._select_all_next_edit = False
|
||||||
|
|
||||||
# Edge auto-scroll during a drag (Qt's built-in autoscroll never runs
|
# Edge auto-scroll during a drag (Qt's built-in autoscroll never runs
|
||||||
# because our dragMoveEvent accepts the event itself). Mirrors
|
# because our dragMoveEvent accepts the event itself). Mirrors
|
||||||
@@ -293,6 +301,7 @@ class PlaylistTree(QTreeWidget):
|
|||||||
item = self._find_item(playlist.persistent_id)
|
item = self._find_item(playlist.persistent_id)
|
||||||
if item is not None:
|
if item is not None:
|
||||||
self.setCurrentItem(item) # selects + navigates to the new playlist
|
self.setCurrentItem(item) # selects + navigates to the new playlist
|
||||||
|
self._select_all_next_edit = True
|
||||||
self.editItem(item, 0) # live inline rename; editor selects all
|
self.editItem(item, 0) # live inline rename; editor selects all
|
||||||
|
|
||||||
def create_smart_playlist_interactive(self, parent_pid: str = ""):
|
def create_smart_playlist_interactive(self, parent_pid: str = ""):
|
||||||
@@ -309,6 +318,7 @@ class PlaylistTree(QTreeWidget):
|
|||||||
item = self._find_item(playlist.persistent_id)
|
item = self._find_item(playlist.persistent_id)
|
||||||
if item is not None:
|
if item is not None:
|
||||||
self.setCurrentItem(item)
|
self.setCurrentItem(item)
|
||||||
|
self._select_all_next_edit = True
|
||||||
self.editItem(item, 0)
|
self.editItem(item, 0)
|
||||||
|
|
||||||
def edit_smart_playlist(self, pid: str):
|
def edit_smart_playlist(self, pid: str):
|
||||||
@@ -329,6 +339,25 @@ class PlaylistTree(QTreeWidget):
|
|||||||
if current.data(0, KIND_ROLE) in ("playlist", "smart"):
|
if current.data(0, KIND_ROLE) in ("playlist", "smart"):
|
||||||
self.playlist_selected.emit(current.data(0, PID_ROLE))
|
self.playlist_selected.emit(current.data(0, PID_ROLE))
|
||||||
|
|
||||||
|
def edit(self, index, trigger=QAbstractItemView.EditTrigger.AllEditTriggers,
|
||||||
|
event=None):
|
||||||
|
"""Every inline rename (click, double-click, F2, context menu) comes
|
||||||
|
through here. Qt selects the editor's whole text inside this call,
|
||||||
|
after the delegate has run, so renaming an existing playlist undoes
|
||||||
|
that afterwards: nothing selected, cursor at the end. Defaulted args
|
||||||
|
because this override shadows PyQt's one-argument edit() slot too."""
|
||||||
|
opened = super().edit(index, trigger, event)
|
||||||
|
# A SelectedClicked edit returns True having only armed Qt's
|
||||||
|
# double-click timer; the editor opens when that fires (through here
|
||||||
|
# again), so only a real editor settles the one-shot flag.
|
||||||
|
editor = self.indexWidget(index) if opened else None
|
||||||
|
if isinstance(editor, QLineEdit):
|
||||||
|
if not self._select_all_next_edit:
|
||||||
|
editor.deselect()
|
||||||
|
editor.end(False)
|
||||||
|
self._select_all_next_edit = False
|
||||||
|
return opened
|
||||||
|
|
||||||
def _on_item_renamed(self, item, _column):
|
def _on_item_renamed(self, item, _column):
|
||||||
pid = item.data(0, PID_ROLE)
|
pid = item.data(0, PID_ROLE)
|
||||||
if pid:
|
if pid:
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
## Done
|
## Done
|
||||||
|
|
||||||
|
### Round 57 (2026-09-18) — click to rename (v0.21.0)
|
||||||
|
|
||||||
|
- [x] **Click a selected playlist to rename it.** The sidebar tree gains
|
||||||
|
Qt's `SelectedClicked` trigger alongside double-click/F2, so the
|
||||||
|
second click on the current playlist opens inline rename (after the
|
||||||
|
double-click interval; a press that becomes a drag never opens it).
|
||||||
|
- [x] **Rename opens with the cursor at the end, nothing selected.** Qt
|
||||||
|
selects the editor's text inside `QAbstractItemView::edit()`, after
|
||||||
|
the delegate runs, so `PlaylistTree.edit()` undoes it afterwards —
|
||||||
|
every route (click, double-click, F2, context menu) goes through it.
|
||||||
|
A brand-new playlist still selects all (one-shot
|
||||||
|
`_select_all_next_edit`), so typing replaces "untitled playlist".
|
||||||
|
|
||||||
### Round 56 (2026-09-17) — one press, sixteen hundred songs (v0.20.6)
|
### Round 56 (2026-09-17) — one press, sixteen hundred songs (v0.20.6)
|
||||||
|
|
||||||
One Right-arrow press skipped 1,676 tracks in 81 s and hard-froze GNOME badly
|
One Right-arrow press skipped 1,676 tracks in 81 s and hard-froze GNOME badly
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Round 57: click an already-selected playlist to rename it.
|
||||||
|
|
||||||
|
Clicking the selected playlist in the sidebar opens inline rename (Qt's
|
||||||
|
SelectedClicked trigger), and renaming an existing playlist opens the editor
|
||||||
|
with nothing selected and the cursor at the end rather than Qt's select-all.
|
||||||
|
A brand-new playlist still selects all, so typing replaces "untitled playlist".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PyQt6.QtCore import Qt
|
||||||
|
from PyQt6.QtTest import QTest
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QAbstractItemDelegate, QAbstractItemView, QApplication, QLineEdit,
|
||||||
|
)
|
||||||
|
|
||||||
|
from lintunes.models import Library, Track
|
||||||
|
from lintunes.library_manager import LibraryManager
|
||||||
|
from lintunes.gui.sidebar import PlaylistTree
|
||||||
|
|
||||||
|
SelectedClicked = QAbstractItemView.EditTrigger.SelectedClicked
|
||||||
|
|
||||||
|
|
||||||
|
def _tree(qapp, tmp_path):
|
||||||
|
library = Library(tracks={1: Track(track_id=1, name="One")})
|
||||||
|
manager = LibraryManager(library, tmp_path)
|
||||||
|
tree = PlaylistTree(manager)
|
||||||
|
playlist = manager.create_playlist("Road Trip")
|
||||||
|
tree.resize(200, 300)
|
||||||
|
tree.show()
|
||||||
|
item = tree._find_item(playlist.persistent_id)
|
||||||
|
tree.setCurrentItem(item)
|
||||||
|
return tree, manager, playlist, item
|
||||||
|
|
||||||
|
|
||||||
|
def _editor(tree, item):
|
||||||
|
editor = tree.indexWidget(tree.indexFromItem(item, 0))
|
||||||
|
assert isinstance(editor, QLineEdit)
|
||||||
|
return editor
|
||||||
|
|
||||||
|
|
||||||
|
def test_selected_click_is_an_edit_trigger(qapp, tmp_path):
|
||||||
|
tree, *_ = _tree(qapp, tmp_path)
|
||||||
|
assert tree.editTriggers() & SelectedClicked
|
||||||
|
# Qt's defaults survive alongside it.
|
||||||
|
assert tree.editTriggers() & QAbstractItemView.EditTrigger.DoubleClicked
|
||||||
|
assert tree.editTriggers() & QAbstractItemView.EditTrigger.EditKeyPressed
|
||||||
|
|
||||||
|
|
||||||
|
def test_click_rename_puts_cursor_at_end(qapp, tmp_path):
|
||||||
|
tree, _, _, item = _tree(qapp, tmp_path)
|
||||||
|
center = tree.visualItemRect(item).center()
|
||||||
|
# The row is already current; a real click on it arms Qt's delayed
|
||||||
|
# editing, which opens the editor once the double-click interval passes.
|
||||||
|
QTest.mouseClick(tree.viewport(), Qt.MouseButton.LeftButton, pos=center)
|
||||||
|
QTest.qWait(QApplication.doubleClickInterval() + 100)
|
||||||
|
editor = _editor(tree, item)
|
||||||
|
assert editor.text() == "Road Trip"
|
||||||
|
assert not editor.hasSelectedText()
|
||||||
|
assert editor.cursorPosition() == len("Road Trip")
|
||||||
|
|
||||||
|
|
||||||
|
def test_context_menu_rename_puts_cursor_at_end(qapp, tmp_path):
|
||||||
|
tree, _, _, item = _tree(qapp, tmp_path)
|
||||||
|
tree.editItem(item, 0)
|
||||||
|
editor = _editor(tree, item)
|
||||||
|
assert not editor.hasSelectedText()
|
||||||
|
assert editor.cursorPosition() == len("Road Trip")
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_playlist_still_selects_all(qapp, tmp_path):
|
||||||
|
tree, manager, _, _ = _tree(qapp, tmp_path)
|
||||||
|
tree.create_playlist_interactive()
|
||||||
|
item = tree.currentItem()
|
||||||
|
editor = _editor(tree, item)
|
||||||
|
assert editor.selectedText() == "untitled playlist"
|
||||||
|
# The select-all is one-shot: the next rename is back to cursor-at-end.
|
||||||
|
tree.closeEditor(editor, QAbstractItemDelegate.EndEditHint.NoHint)
|
||||||
|
tree.editItem(item, 0)
|
||||||
|
assert not _editor(tree, item).hasSelectedText()
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_matching_trigger_does_not_consume_select_all(qapp, tmp_path):
|
||||||
|
tree, _, _, item = _tree(qapp, tmp_path)
|
||||||
|
tree._select_all_next_edit = True
|
||||||
|
tree.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||||
|
assert not tree.edit(tree.indexFromItem(item, 0), SelectedClicked, None)
|
||||||
|
assert tree._select_all_next_edit
|
||||||
|
|
||||||
|
|
||||||
|
def test_committing_the_edit_renames(qapp, tmp_path):
|
||||||
|
tree, manager, playlist, item = _tree(qapp, tmp_path)
|
||||||
|
tree.editItem(item, 0)
|
||||||
|
editor = _editor(tree, item)
|
||||||
|
editor.insert(" 2026")
|
||||||
|
tree.commitData(editor)
|
||||||
|
assert manager.library.playlists[playlist.persistent_id].name == \
|
||||||
|
"Road Trip 2026"
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_click_only_selects(qapp, tmp_path):
|
||||||
|
tree, manager, _, _ = _tree(qapp, tmp_path)
|
||||||
|
other = manager.create_playlist("Other")
|
||||||
|
item = tree._find_item(other.persistent_id)
|
||||||
|
QTest.mouseClick(tree.viewport(), Qt.MouseButton.LeftButton,
|
||||||
|
pos=tree.visualItemRect(item).center())
|
||||||
|
QTest.qWait(QApplication.doubleClickInterval() + 100)
|
||||||
|
assert tree.currentItem() is item
|
||||||
|
assert tree.indexWidget(tree.indexFromItem(item, 0)) is None
|
||||||
Reference in New Issue
Block a user