v0.1.2: transport buttons fill their bubble as equal tap targets

Prev/play/next (and shuffle) used to hug their ~20px glyphs in the middle
of the 62px rounded box, so most of the bubble was dead space. _box() now
takes split=True: the buttons tile the bubble interior in equal shares,
expanding to the (tightened) margins with the glyph centered in each.
Minimum button widths keep the bubbles at their old footprint — no bigger,
no smaller. Round 21 tests cover the equal split, vertical fill, edge
spans, and the preserved footprint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 17:03:54 -04:00
parent 332b47a018
commit f94a66963b
4 changed files with 114 additions and 10 deletions

View File

@ -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**.

View File

@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.1.1"
__version__ = "0.1.2"

View File

@ -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)

75
tests/test_round21.py Normal file
View File

@ -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