diff --git a/lintunes/__init__.py b/lintunes/__init__.py index 0d87d85..dfe003b 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.1" +__version__ = "0.20.2" diff --git a/lintunes/gui/main_window.py b/lintunes/gui/main_window.py index e92aeda..7e647db 100644 --- a/lintunes/gui/main_window.py +++ b/lintunes/gui/main_window.py @@ -50,8 +50,9 @@ def album_tracks(library, artist: str, album: str) -> list: class _CancelSyncButton(QPushButton): - """Compact "✕" beside the sync progress bar that expands to say what it - does ("cancel transfer") while hovered, then shrinks back.""" + """Compact "✕" in the status bar's right corner that expands to say what + it does ("cancel transfer") while hovered, then shrinks back. Expanding + pushes the progress bar and label left; the button itself stays put.""" def __init__(self, parent=None): super().__init__(parent) @@ -149,10 +150,10 @@ class MainWindow(QMainWindow): # status bar's right and survive showMessage. No stretch (see above) # and hidden while idle so it costs no space. Text lives in its own # label — QProgressBar's overlay can't fit a sentence at this width. - self._sync_cancel = _CancelSyncButton() - self._sync_cancel.hide() - self._sync_cancel.clicked.connect(self._confirm_cancel_sync) - self.statusBar().addPermanentWidget(self._sync_cancel) + # The group is laid out left to right but justified right, so whatever + # is added *last* owns the corner and never moves. The cancel button + # goes there deliberately: it is the one widget the user aims at, and + # the label beside it is as wide as the song title of the moment. self._sync_label = QLabel() self._sync_label.hide() self.statusBar().addPermanentWidget(self._sync_label) @@ -161,6 +162,10 @@ class MainWindow(QMainWindow): self._sync_progress.setTextVisible(False) self._sync_progress.hide() self.statusBar().addPermanentWidget(self._sync_progress) + self._sync_cancel = _CancelSyncButton() + self._sync_cancel.hide() + self._sync_cancel.clicked.connect(self._confirm_cancel_sync) + self.statusBar().addPermanentWidget(self._sync_cancel) self._andtunes_worker = None self._install_worker = None self._sync_device_name = "" diff --git a/tests/test_round53.py b/tests/test_round53.py new file mode 100644 index 0000000..9546938 --- /dev/null +++ b/tests/test_round53.py @@ -0,0 +1,90 @@ +"""Round 53 — the cancel button stops flying around. + +The status bar's transfer widgets are permanent widgets, so Qt lays them out +left to right but justifies the group right: whichever one is added *last* +owns the corner and never moves, and everything before it slides whenever the +group's total width changes. The label carries the song title of the moment, +so it changes width on nearly every track — which used to drag the ✕ across +the bar, since it was added first. + +So the ✕ is added last. It sits in the corner; the label absorbs the movement. +Hovering it is the one thing that may still shift the row, because the button +expands to say "cancel transfer" — trav asked for that to stay. +""" +import pytest +from PyQt6.QtCore import QEvent, QPointF +from PyQt6.QtGui import QEnterEvent + +from lintunes.library_manager import LibraryManager +from lintunes.models import Library, Track +from lintunes.preferences import Preferences + + +def _library(tmp_path): + media = tmp_path / "media" + library = Library(music_folder=str(media)) + path = media / "Music" / "Artist" / "Album" / "T1.mp3" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"audio") + library.tracks[1] = Track(track_id=1, name="T1", artist="Artist", + location=str(path)) + return library + + +@pytest.fixture +def window(qapp, tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + from lintunes.gui.main_window import MainWindow + manager = LibraryManager(_library(tmp_path), tmp_path / "data") + win = MainWindow(manager, Preferences(tmp_path / "data")) + win.resize(1280, 800) + win.show() + for widget in (win._sync_label, win._sync_progress, win._sync_cancel): + widget.show() + qapp.processEvents() + yield win + win.close() + + +def _right_edge(widget): + return widget.x() + widget.width() + + +class TestCancelButtonStaysInTheCorner: + + def test_a_longer_song_title_does_not_move_the_button(self, window, qapp): + window._sync_label.setText("Rabbit R1 · 3/40 04 Song") + qapp.processEvents() + before = window._sync_cancel.x() + window._sync_label.setText( + "Rabbit R1 · 12/40 07 A Considerably Longer Song Title Indeed") + qapp.processEvents() + assert window._sync_label.width() > 0 + assert window._sync_cancel.x() == before + + def test_the_button_is_the_rightmost_of_the_three(self, window, qapp): + window._sync_label.setText("Rabbit R1 · 3/40 04 Song") + qapp.processEvents() + assert (_right_edge(window._sync_label) + <= window._sync_progress.x()) + assert (_right_edge(window._sync_progress) + <= window._sync_cancel.x()) + + def test_hovering_expands_leftwards(self, window, qapp): + window._sync_label.setText("Rabbit R1 · 3/40 04 Song") + qapp.processEvents() + edge = _right_edge(window._sync_cancel) + progress_x = window._sync_progress.x() + + narrow = window._sync_cancel.width() + here = QPointF(1, 1) + window._sync_cancel.enterEvent(QEnterEvent(here, here, here)) + qapp.processEvents() + assert window._sync_cancel.width() > narrow + assert _right_edge(window._sync_cancel) == edge + assert window._sync_progress.x() < progress_x + + window._sync_cancel.leaveEvent(QEvent(QEvent.Type.Leave)) + qapp.processEvents() + assert _right_edge(window._sync_cancel) == edge + assert window._sync_progress.x() == progress_x