diff --git a/lintunes/__init__.py b/lintunes/__init__.py index 4307ba2..d248bc1 100644 --- a/lintunes/__init__.py +++ b/lintunes/__init__.py @@ -1,3 +1,3 @@ """LinTunes — iTunes-style music library manager and player for Linux.""" -__version__ = "0.20.6" +__version__ = "0.21.0" diff --git a/lintunes/gui/sidebar.py b/lintunes/gui/sidebar.py index 9676ef4..f390aba 100644 --- a/lintunes/gui/sidebar.py +++ b/lintunes/gui/sidebar.py @@ -1,6 +1,6 @@ from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QPushButton, QTreeWidget, QTreeWidgetItem, QMenu, - QInputDialog, QMessageBox, QAbstractItemView, + QInputDialog, QMessageBox, QAbstractItemView, QLineEdit, ) from PyQt6.QtCore import Qt, QTimer, QPoint, pyqtSignal from PyQt6.QtGui import QBrush, QPainter, QPixmap @@ -175,6 +175,14 @@ class PlaylistTree(QTreeWidget): self.customContextMenuRequested.connect(self._show_context_menu) self.currentItemChanged.connect(self._on_selection_changed) 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 # because our dragMoveEvent accepts the event itself). Mirrors @@ -293,6 +301,7 @@ class PlaylistTree(QTreeWidget): item = self._find_item(playlist.persistent_id) if item is not None: 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 def create_smart_playlist_interactive(self, parent_pid: str = ""): @@ -309,6 +318,7 @@ class PlaylistTree(QTreeWidget): item = self._find_item(playlist.persistent_id) if item is not None: self.setCurrentItem(item) + self._select_all_next_edit = True self.editItem(item, 0) def edit_smart_playlist(self, pid: str): @@ -329,6 +339,25 @@ class PlaylistTree(QTreeWidget): if current.data(0, KIND_ROLE) in ("playlist", "smart"): 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): pid = item.data(0, PID_ROLE) if pid: diff --git a/tasks-done.md b/tasks-done.md index 0d4196f..38709fd 100644 --- a/tasks-done.md +++ b/tasks-done.md @@ -1,5 +1,18 @@ ## 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) One Right-arrow press skipped 1,676 tracks in 81 s and hard-froze GNOME badly diff --git a/tests/test_round57.py b/tests/test_round57.py new file mode 100644 index 0000000..097deac --- /dev/null +++ b/tests/test_round57.py @@ -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