diff --git a/TASKS.md b/TASKS.md index 15ce161..5e59e94 100644 --- a/TASKS.md +++ b/TASKS.md @@ -3,7 +3,17 @@ Legend: `[ ]` todo · `[~]` in progress · `[x]` done. When a round closes, move its finished items to `tasks-done.md`. -## Round 20 (current) — Ctrl+I save hang on the Debian machine (v0.1.1) +## Round 21 — transport buttons fill their bubble (v0.1.2) + +Plan reference: `~/.claude/plans/tap-targets-on-the-snazzy-minsky.md`. +Tests in `tests/test_round21.py`. Fix-only round → patch bump **0.1.2**. + +- [x] Prev/play/next (and shuffle) tap targets now tile their rounded box: + `_box(split=True)` in `gui/transport.py` gives each button an equal, + full-height share of the bubble with the glyph centered; minimum + button widths keep the bubbles at their old footprint (no bigger). + +## Round 20 — Ctrl+I save hang on the Debian machine (v0.1.1) Plan reference: `~/.claude/plans/i-m-having-some-trouble-eager-gosling.md`. Tests in `tests/test_round20.py`. Fix-only round → patch bump **0.1.1**. diff --git a/lintunes/__init__.py b/lintunes/__init__.py index 6d8e3af..40b2881 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.1.1" +__version__ = "0.1.2" diff --git a/lintunes/gui/transport.py b/lintunes/gui/transport.py index 074f1d6..db69883 100644 --- a/lintunes/gui/transport.py +++ b/lintunes/gui/transport.py @@ -90,22 +90,33 @@ class FlashButton(QToolButton): def _box(*widgets, bg: QColor, margins=(10, 3, 10, 3), hug=False, - height=None) -> QFrame: + height=None, split=False) -> QFrame: """A small rounded frame with its content centered inside. With hug=True the frame stays at its size hint instead of stretching to fill the row it sits in. height pins the frame's height (width is left untouched), so several boxes can be lined up to the same height. - bg is the rounded-rectangle fill (a user-tunable grayscale).""" + bg is the rounded-rectangle fill (a user-tunable grayscale). + + With split=True the widgets tile the frame's interior in equal shares + instead of hugging their size hints in the middle — each widget becomes + a full-bleed tap target with its content centered in its share.""" frame = QFrame() frame.setObjectName("transportBox") frame.setStyleSheet(_box_style(bg)) inner = QHBoxLayout(frame) inner.setContentsMargins(*margins) - inner.addStretch() - for widget in widgets: - inner.addWidget(widget) - inner.addStretch() + if split: + inner.setSpacing(0) + for widget in widgets: + widget.setSizePolicy(QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Expanding) + inner.addWidget(widget, stretch=1) + else: + inner.addStretch() + for widget in widgets: + inner.addWidget(widget) + inner.addStretch() if height is not None: frame.setFixedHeight(height) if hug: @@ -242,15 +253,23 @@ class TransportBar(QWidget): self._prev_btn = FlashButton("previous") self._play_btn = FlashButton("play") self._next_btn = FlashButton("next") + # split=True turns each button into a full-bleed tap target tiling the + # bubble; the minimum widths keep the bubbles at their pre-split + # footprint (the centering stretches/margins used to pad them out). + for btn in (self._prev_btn, self._play_btn, self._next_btn): + btn.setMinimumWidth(36) transport_box = _box(self._prev_btn, self._play_btn, self._next_btn, - bg=box_bg, height=CONTROL_HEIGHT) + bg=box_bg, margins=(3, 3, 3, 3), + height=CONTROL_HEIGHT, split=True) self._boxes.append(transport_box) layout.addWidget(transport_box) self._shuffle_btn = FlashButton("shuffle") + self._shuffle_btn.setMinimumWidth(50) self._shuffle_btn.setCheckable(True) self._shuffle_btn.setToolTip("Shuffle (doesn't change playlist order)") - shuffle_box = _box(self._shuffle_btn, bg=box_bg, height=CONTROL_HEIGHT) + shuffle_box = _box(self._shuffle_btn, bg=box_bg, margins=(3, 3, 3, 3), + height=CONTROL_HEIGHT, split=True) self._boxes.append(shuffle_box) layout.addWidget(shuffle_box) diff --git a/tests/test_round21.py b/tests/test_round21.py new file mode 100644 index 0000000..f0b96dd --- /dev/null +++ b/tests/test_round21.py @@ -0,0 +1,75 @@ +"""Round 21: transport buttons fill their bubble as equal tap targets. + +The prev/play/next FlashButtons used to hug their ~20px icons in the middle +of the 62px-tall rounded box, leaving most of the bubble unclickable. Now +_box(split=True) tiles the bubble interior with the buttons: each gets an +equal third, expands vertically to the margins, and keeps its glyph centered. +The bubble itself keeps its pre-split footprint (no bigger, no smaller). +""" + +from unittest.mock import MagicMock, patch + +from lintunes.models import Library, Track +from lintunes.library_manager import LibraryManager +from lintunes.preferences import Preferences +from lintunes import player as player_module +from lintunes.player import Player +from lintunes.gui.transport import TransportBar, CONTROL_HEIGHT + + +def _mock_player(): + """A real Player with the Qt multimedia backend stubbed out (it blocks on + init headless — see tests/test_player.py).""" + library = Library(tracks={1: Track(track_id=1, name="One")}) + with patch.multiple( + player_module, + QMediaPlayer=MagicMock(), + QAudioOutput=MagicMock(), + QAudioBufferOutput=MagicMock(), + QMediaDevices=MagicMock(), + ): + return Player(MagicMock(library=library)) + + +def _build_bar(qapp, tmp_path): + prefs = Preferences(tmp_path) + manager = LibraryManager( + Library(tracks={1: Track(track_id=1, name="One")}), tmp_path) + bar = TransportBar(_mock_player(), manager, prefs) + bar.resize(1200, 90) + bar.show() + qapp.processEvents() + return bar + + +class TestTransportTapTargets: + def test_buttons_split_the_bubble_equally(self, qapp, tmp_path): + bar = _build_bar(qapp, tmp_path) + widths = [b.width() for b in + (bar._prev_btn, bar._play_btn, bar._next_btn)] + assert max(widths) - min(widths) <= 1 + + def test_buttons_fill_the_bubble_vertically(self, qapp, tmp_path): + bar = _build_bar(qapp, tmp_path) + for btn in (bar._prev_btn, bar._play_btn, bar._next_btn, + bar._shuffle_btn): + # Bubble height minus margins+border (4px each side). + assert btn.height() >= CONTROL_HEIGHT - 10 + + def test_buttons_span_the_bubble_horizontally(self, qapp, tmp_path): + bar = _build_bar(qapp, tmp_path) + box = bar._prev_btn.parentWidget() + assert bar._prev_btn.x() <= 5 # flush against the left margin + right_edge = bar._next_btn.x() + bar._next_btn.width() + assert right_edge >= box.width() - 5 # ...and the right one + # No stretch gaps between the buttons. + assert bar._play_btn.x() == bar._prev_btn.x() + bar._prev_btn.width() + assert bar._next_btn.x() == bar._play_btn.x() + bar._play_btn.width() + + def test_bubble_keeps_its_footprint(self, qapp, tmp_path): + """The bubble must not grow (trav's constraint) nor shrink (the + minimum button widths preserve the old stretch-padded size).""" + bar = _build_bar(qapp, tmp_path) + box = bar._prev_btn.parentWidget() + assert box.height() == CONTROL_HEIGHT + assert 106 <= box.width() <= 126 # was 116 pre-split