Three reported symptoms, one shape: every operation was whole-library, whole-file, on the Qt main thread. Measured on the real library (21,482 tracks, 506 playlists, 15 MB library.json). The track table sorted through a QSortFilterProxyModel, which asks data() for a value on every comparison — 580k Python round trips, 8.5 s per table load, paid again on every reload. TrackTableModel now keeps _tracks in canonical (playlist) order plus an _order index list and sorts a key list computed once per track. Nothing used the proxy's filtering. Sorting by # is the identity order, so "source row" still means "playlist position" for drag-reorder. 8.5 s -> 0.13 s; MainWindow() 18.1 s -> 0.79 s. Smart rules compile to closures once per evaluate() instead of being re-dispatched per track — the operator lookup, casefolding the query and parsing the rule's own date constants all leave the 21k-iteration loop (_match_date was re-parsing its own constant 21,482 times per rule). Verified identical membership against the old evaluator on all 20 real smart playlists. 2.54 s -> 0.72 s, and it now runs after the first paint. Also: _reconcile_track skips two to_dict() round trips per unchanged track (MERGEABLE_FIELDS in the resolver is the single source of truth for what a merge touches); no git subprocess on the startup path (Updater.enabled is probed lazily on the background thread, version-button tooltip deferred); and .resolved/ is pruned to the newest 10 snapshots — it had reached 1.7 GB across 73 snapshots inside the Syncthing share. Time to interactive window ~15 s -> 3.2 s. The mid-session freeze when Syncthing delivers a change — a full reload + recompute + table rebuild with the window already on screen, which is what GNOME was offering to force-quit — ~16 s -> 3.8 s. The merge rework itself (playlist ordering, per-machine play journal, quieting the dialog) is queued in TASKS.md as Round 36. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
136 lines
5.3 KiB
Python
136 lines
5.3 KiB
Python
"""Round 12: cut (Ctrl-X), paste-above-selection, scrollbar click-to-jump.
|
|
|
|
- Cut copies the selection to the clipboard and, in a playlist, removes it
|
|
*immediately* (so it visibly leaves); a paste re-inserts it at the target.
|
|
A cut from the library has nothing to remove, so it's just a copy.
|
|
- Paste inserts *above the currently selected track*, not at the end.
|
|
- Clicking the scrollbar trough jumps there instead of paging a step.
|
|
"""
|
|
|
|
from PyQt6.QtWidgets import QApplication, QStyle
|
|
|
|
from lintunes.models import Library, Track
|
|
from lintunes.library_manager import LibraryManager
|
|
from lintunes.gui.track_table import (
|
|
TrackTableView, make_tracks_mime, parse_tracks_mime, ClickToJumpScrollStyle)
|
|
from lintunes.gui.playlist_view import PlaylistView
|
|
|
|
|
|
def _manager(tmp_path, n=4):
|
|
library = Library(tracks={i: Track(track_id=i, name=f"T{i}")
|
|
for i in range(1, n + 1)})
|
|
return LibraryManager(library, tmp_path)
|
|
|
|
|
|
def _select_source_row(table, source_row):
|
|
"""Select the visual row backing a given source-model row (sort-proof)."""
|
|
table.selectRow(table.model_.display_row(source_row))
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# clipboard helpers + cut signalling
|
|
# --------------------------------------------------------------------------
|
|
|
|
class TestCutSelection:
|
|
def test_paste_anchor_row(self, qapp, tmp_path):
|
|
manager = _manager(tmp_path)
|
|
table = TrackTableView(playlist_mode=True)
|
|
table.set_tracks([manager.library.tracks[i] for i in (1, 2, 3)])
|
|
assert table.paste_anchor_row() is None # nothing selected
|
|
_select_source_row(table, 1)
|
|
assert table.paste_anchor_row() == 1
|
|
|
|
def test_cut_in_playlist_copies_and_requests_removal(self, qapp, tmp_path):
|
|
manager = _manager(tmp_path)
|
|
table = TrackTableView(playlist_mode=True)
|
|
table.set_source_playlist("PID")
|
|
table.set_tracks([manager.library.tracks[i] for i in (1, 2, 3)])
|
|
removed = []
|
|
table.cut_requested.connect(removed.append)
|
|
|
|
_select_source_row(table, 0)
|
|
table.cut_selection()
|
|
|
|
assert removed == [[0]] # asked the view to remove row 0 immediately
|
|
payload = parse_tracks_mime(QApplication.clipboard().mimeData())
|
|
assert payload["track_ids"] == [1]
|
|
|
|
def test_cut_in_library_is_just_a_copy(self, qapp, tmp_path):
|
|
manager = _manager(tmp_path)
|
|
table = TrackTableView(playlist_mode=False) # library
|
|
table.set_tracks([manager.library.tracks[i] for i in (1, 2, 3)])
|
|
removed = []
|
|
table.cut_requested.connect(removed.append)
|
|
|
|
_select_source_row(table, 0)
|
|
table.cut_selection()
|
|
|
|
assert removed == [] # nothing to remove from the library
|
|
payload = parse_tracks_mime(QApplication.clipboard().mimeData())
|
|
assert payload["track_ids"] == [1]
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# scrollbar click-to-jump
|
|
# --------------------------------------------------------------------------
|
|
|
|
class TestScrollbarJump:
|
|
def test_style_hint_enables_absolute_position(self, qapp):
|
|
"""Applied app-wide in main.run_gui via app.setStyle(); here we just
|
|
confirm the proxy flips the left-click-jump hint on."""
|
|
style = ClickToJumpScrollStyle()
|
|
assert style.styleHint(
|
|
QStyle.StyleHint.SH_ScrollBar_LeftClickAbsolutePosition) == 1
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# PlaylistView integration
|
|
# --------------------------------------------------------------------------
|
|
|
|
class TestPasteIntegration:
|
|
def test_copy_paste_inserts_above_selection(self, qapp, tmp_path):
|
|
manager = _manager(tmp_path)
|
|
b = manager.create_playlist("B")
|
|
manager.add_tracks_to_playlist(b.persistent_id, [4])
|
|
view = PlaylistView(manager)
|
|
view.show_playlist(b)
|
|
|
|
QApplication.clipboard().setMimeData(make_tracks_mime([1, 2]))
|
|
_select_source_row(view.table, 0) # above track 4
|
|
view._on_paste()
|
|
|
|
assert b.track_ids == [1, 2, 4]
|
|
|
|
def test_cut_removes_now_then_paste_reinserts_elsewhere(self, qapp, tmp_path):
|
|
manager = _manager(tmp_path)
|
|
a = manager.create_playlist("A")
|
|
b = manager.create_playlist("B")
|
|
manager.add_tracks_to_playlist(a.persistent_id, [1, 2, 3])
|
|
manager.add_tracks_to_playlist(b.persistent_id, [4])
|
|
view = PlaylistView(manager)
|
|
|
|
view.show_playlist(a)
|
|
_select_source_row(view.table, 0) # track 1
|
|
view.table.cut_selection()
|
|
assert a.track_ids == [2, 3] # gone immediately
|
|
|
|
view.show_playlist(b)
|
|
_select_source_row(view.table, 0) # above track 4
|
|
view._on_paste()
|
|
assert b.track_ids == [1, 4]
|
|
|
|
def test_cut_paste_same_playlist_reorders(self, qapp, tmp_path):
|
|
manager = _manager(tmp_path)
|
|
a = manager.create_playlist("A")
|
|
manager.add_tracks_to_playlist(a.persistent_id, [1, 2, 3, 4])
|
|
view = PlaylistView(manager)
|
|
view.show_playlist(a)
|
|
|
|
_select_source_row(view.table, 0) # track 1
|
|
view.table.cut_selection()
|
|
assert a.track_ids == [2, 3, 4]
|
|
|
|
_select_source_row(view.table, 1) # above track 3
|
|
view._on_paste()
|
|
assert a.track_ids == [2, 1, 3, 4]
|