From 82d2936f1bc0fb1ce708eaa3228f562d344f67d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 09:03:03 -0400 Subject: [PATCH 1/2] fix(ledmatrix-music): size the progress bar to the text, not the whole panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The progress bar was drawn at the full width of the text area regardless of how much of that area the text actually filled. With a short track title on a wide panel that left a bar stretching right across the display under a few characters. Trimming cannot help here: the bar is ink, so there is nothing to reclaim — it has to be drawn narrower in the first place. It is now sized to the widest of the title, artist and album lines, with a 24px floor so a very short title still leaves something recognisable as a progress indicator. A line long enough to scroll measures wider than the area and so pins the bar to full width, which is correct — that line really does fill it. The album line is only counted when there is height to draw it. Font measurement failures fall back to the full-width bar rather than losing the bar entirely. Set progress_bar_match_text false to restore the previous behaviour. Note the safety harness cannot cover this: its mock track has no duration, so the bar is never drawn and the rendered extent is byte-identical with and without the change (verified by running the harness against both). Hence the focused unit tests in test_progress_bar_width.py, which exercise the sizing rule directly. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ --- plugins.json | 4 +- plugins/ledmatrix-music/CHANGELOG.md | 12 ++ plugins/ledmatrix-music/config_schema.json | 6 + plugins/ledmatrix-music/manager.py | 69 ++++++++- plugins/ledmatrix-music/manifest.json | 8 +- .../test_progress_bar_width.py | 139 ++++++++++++++++++ 6 files changed, 232 insertions(+), 6 deletions(-) create mode 100644 plugins/ledmatrix-music/CHANGELOG.md create mode 100644 plugins/ledmatrix-music/test_progress_bar_width.py diff --git a/plugins.json b/plugins.json index 41ef668a..a1360842 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-07-28", + "last_updated": "2026-07-29", "plugins": [ { "id": "cricket-scoreboard", @@ -508,7 +508,7 @@ "last_updated": "2026-07-17", "verified": true, "screenshot": "", - "latest_version": "1.1.2" + "latest_version": "1.2.0" }, { "id": "news", diff --git a/plugins/ledmatrix-music/CHANGELOG.md b/plugins/ledmatrix-music/CHANGELOG.md new file mode 100644 index 00000000..68464444 --- /dev/null +++ b/plugins/ledmatrix-music/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +## [1.2.0] - 2026-07-29 + +### Changed +- **Progress bar now matches the text width**: the bar spanned the whole text + area regardless of how much of it the text filled, so a short track title on + a wide panel left a bar stretching across the display. It is now sized to the + widest of the title, artist and album lines. A line long enough to scroll + still fills the bar, since that line genuinely fills the width. Disable with + `progress_bar_match_text` for the original behaviour. + diff --git a/plugins/ledmatrix-music/config_schema.json b/plugins/ledmatrix-music/config_schema.json index 779e85ff..b2fb081b 100644 --- a/plugins/ledmatrix-music/config_schema.json +++ b/plugins/ledmatrix-music/config_schema.json @@ -23,6 +23,12 @@ "default": "classic", "description": "Layout engine. 'classic' is the original fixed-size layout (unchanged). 'adaptive' (beta) scales the title/artist/album fonts to the panel height, growing on large panels and shrinking gracefully on small ones — the album art already scales this way. Your customization fonts and y_percent offsets still apply in adaptive mode. Requires LEDMatrix core with the adaptive layout system; falls back to classic on older cores. Switch back to 'classic' at any time to restore the original rendering." }, + "progress_bar_match_text": { + "x-advanced": true, + "type": "boolean", + "default": true, + "description": "Size the progress bar to the widest of the title, artist and album lines instead of stretching it across the whole text area. On a wide panel a short title otherwise leaves a bar spanning the display, which reads as a full-width element in Vegas scroll mode. A line long enough to scroll still fills the bar, since it genuinely fills that width. Turn off for the original full-width bar." + }, "preferred_source": { "type": "string", "description": "Preferred music source", diff --git a/plugins/ledmatrix-music/manager.py b/plugins/ledmatrix-music/manager.py index 8c350e68..80e95300 100644 --- a/plugins/ledmatrix-music/manager.py +++ b/plugins/ledmatrix-music/manager.py @@ -69,8 +69,12 @@ class MusicPlugin(BasePlugin): with album art, scrolling text, and progress bars. Supports both sources with automatic switching and seamless display updates. """ - - def __init__(self, plugin_id: str, config: Dict[str, Any], + + # Floor for the content-matched progress bar, so a very short title still + # leaves something recognisable as a progress indicator. + MIN_PROGRESS_BAR_WIDTH = 24 + + def __init__(self, plugin_id: str, config: Dict[str, Any], display_manager, cache_manager, plugin_manager): """Initialize the music plugin.""" super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager) @@ -909,6 +913,57 @@ def update(self) -> None: if not self.poll_thread or not self.poll_thread.is_alive(): self.start_polling() + def _progress_bar_width(self, text_area_width, lines): + """Width for the progress bar, matched to the widest line of text. + + The bar used to span the whole text area regardless of how much of it + the text actually filled. With a short title on a wide panel that left + a bar stretching across the display under a few characters, which reads + as a full-width element in a ticker even after blank margins are + trimmed (the bar *is* ink, so there is nothing to trim). + + Sizing it to the widest of title/artist/album ties it to the content. + A line long enough to scroll measures wider than the area and so pins + the bar to full width — correct, because that line really does fill it. + + Set ``progress_bar_match_text`` false to restore the full-width bar. + + Args: + text_area_width: Space available for text, and the maximum width + lines: Iterable of (text, font) pairs, or None for a line that is + not currently drawn + + Returns: + Bar width in pixels, at least MIN_PROGRESS_BAR_WIDTH (or the whole + area if that is narrower) + """ + if not self.config.get('progress_bar_match_text', True): + return text_area_width + + widest = 0 + for line in lines: + if not line: + continue + text, font = line + if not text: + continue + try: + widest = max(widest, self.display_manager.get_text_width(text, font)) + except Exception: + # Font measurement is best-effort; a failure should not lose the + # progress bar entirely. + self.logger.debug( + "MusicPlugin: could not measure text for progress bar width", + exc_info=True, + ) + return text_area_width + + if widest <= 0: + return text_area_width + + floor = min(self.MIN_PROGRESS_BAR_WIDTH, text_area_width) + return max(floor, min(widest, text_area_width)) + def _clip_text_to_width(self, text, font, max_width): """Trim trailing characters so the rendered text fits within max_width px. @@ -1431,7 +1486,15 @@ def _safe_y_percent(value, fallback): progress_ms = current_track_info_snapshot.get('progress_ms', 0) if duration_ms > 0 and text_area_width > 0: - bar_total_width = text_area_width + album_shown = available_height_for_album >= album_height + bar_total_width = self._progress_bar_width( + text_area_width, + [ + (title, font_title), + (artist, font_artist), + (album, font_album) if album_shown else None, + ], + ) filled_ratio = progress_ms / duration_ms filled_width = int(filled_ratio * bar_total_width) diff --git a/plugins/ledmatrix-music/manifest.json b/plugins/ledmatrix-music/manifest.json index e075fe8a..83464f6e 100644 --- a/plugins/ledmatrix-music/manifest.json +++ b/plugins/ledmatrix-music/manifest.json @@ -1,7 +1,7 @@ { "id": "ledmatrix-music", "name": "Music Player - Now Playing", - "version": "1.1.2", + "version": "1.2.0", "description": "Real-time now playing display for Spotify and YouTube Music with album art, scrolling text, and progress bars", "author": "ChuckBuilds", "entry_point": "manager.py", @@ -66,6 +66,12 @@ } ], "versions": [ + { + "released": "2026-07-29", + "version": "1.2.0", + "notes": "Progress bar now matches the widest of the title/artist/album lines instead of always spanning the full text area, so a short track title no longer leaves a bar stretched across a wide panel. A line long enough to scroll still fills the bar. Disable with progress_bar_match_text.", + "ledmatrix_min": "2.0.0" + }, { "released": "2026-07-17", "version": "1.1.2", diff --git a/plugins/ledmatrix-music/test_progress_bar_width.py b/plugins/ledmatrix-music/test_progress_bar_width.py new file mode 100644 index 00000000..2e7af6e3 --- /dev/null +++ b/plugins/ledmatrix-music/test_progress_bar_width.py @@ -0,0 +1,139 @@ +""" +Tests for MusicPlugin._progress_bar_width. + +The safety harness cannot cover this: its mock track has no duration, so the +progress bar is never drawn and the rendered extent is identical with and +without the change. These exercise the sizing rule directly. +""" + +import sys +import types + +import pytest + +# The plugin imports BasePlugin from the core, which is not on the path here. +# Stub just enough for the module to import, then test the method unbound. +if 'src' not in sys.modules: + src = types.ModuleType('src') + plugin_system = types.ModuleType('src.plugin_system') + base_plugin = types.ModuleType('src.plugin_system.base_plugin') + + class _BasePlugin: # minimal stand-in + def __init__(self, *args, **kwargs): + pass + + base_plugin.BasePlugin = _BasePlugin + base_plugin.VegasDisplayMode = None + plugin_system.base_plugin = base_plugin + src.plugin_system = plugin_system + sys.modules['src'] = src + sys.modules['src.plugin_system'] = plugin_system + sys.modules['src.plugin_system.base_plugin'] = base_plugin + + +class FakeDisplayManager: + """Reports text width as a fixed number of pixels per character.""" + + def __init__(self, px_per_char=6, fail=False): + self.px_per_char = px_per_char + self.fail = fail + + def get_text_width(self, text, font=None): + if self.fail: + raise RuntimeError("font unavailable") + return len(text) * self.px_per_char + + +class FakeLogger: + def debug(self, *a, **k): + pass + + +def make_plugin(config=None, px_per_char=6, fail=False): + """Build a MusicPlugin shell without running its real __init__.""" + from manager import MusicPlugin + + plugin = MusicPlugin.__new__(MusicPlugin) + plugin.config = config if config is not None else {} + plugin.display_manager = FakeDisplayManager(px_per_char, fail) + plugin.logger = FakeLogger() + return plugin + + +TEXT_AREA = 400 +FONT = object() + + +class TestProgressBarWidth: + def test_matches_the_widest_line(self): + plugin = make_plugin() + # artist is longest at 10 chars -> 60px + width = plugin._progress_bar_width(TEXT_AREA, [ + ('Song', FONT), # 24px + ('An Artist', FONT), # 54px + ('Alb', FONT), # 18px + ]) + assert width == 54 + + def test_short_title_no_longer_spans_the_panel(self): + # The reported problem: a few characters left a bar across the display. + plugin = make_plugin() + width = plugin._progress_bar_width(TEXT_AREA, [('Hey', FONT), ('Yo', FONT), None]) + assert width < TEXT_AREA + assert width == 24 # the MIN_PROGRESS_BAR_WIDTH floor + + def test_never_exceeds_the_text_area(self): + plugin = make_plugin() + # 200 chars would measure 1200px, far beyond the area. + width = plugin._progress_bar_width(TEXT_AREA, [('x' * 200, FONT)]) + assert width == TEXT_AREA + + def test_scrolling_line_pins_the_bar_to_full_width(self): + # A line long enough to scroll genuinely fills the width, so a + # full-width bar is correct there. + plugin = make_plugin() + assert plugin._progress_bar_width(TEXT_AREA, [('y' * 80, FONT)]) == TEXT_AREA + + def test_floor_is_applied(self): + plugin = make_plugin() + assert plugin._progress_bar_width(TEXT_AREA, [('a', FONT)]) == 24 + + def test_floor_cannot_exceed_a_narrow_text_area(self): + plugin = make_plugin() + assert plugin._progress_bar_width(10, [('a', FONT)]) == 10 + + def test_hidden_album_line_is_ignored(self): + plugin = make_plugin() + with_album = plugin._progress_bar_width( + TEXT_AREA, [('Song', FONT), ('Art', FONT), ('A Very Long Album', FONT)]) + without_album = plugin._progress_bar_width( + TEXT_AREA, [('Song', FONT), ('Art', FONT), None]) + assert with_album > without_album + + def test_empty_strings_are_skipped(self): + plugin = make_plugin() + assert plugin._progress_bar_width( + TEXT_AREA, [('', FONT), ('Four', FONT), None]) == 24 + + def test_all_lines_empty_falls_back_to_full_area(self): + plugin = make_plugin() + assert plugin._progress_bar_width(TEXT_AREA, [('', FONT), None]) == TEXT_AREA + + def test_disabled_by_config_restores_full_width(self): + plugin = make_plugin(config={'progress_bar_match_text': False}) + assert plugin._progress_bar_width(TEXT_AREA, [('Hey', FONT)]) == TEXT_AREA + + def test_enabled_by_default(self): + plugin = make_plugin(config={}) + assert plugin._progress_bar_width(TEXT_AREA, [('Hey', FONT)]) < TEXT_AREA + + def test_font_measurement_failure_keeps_the_bar(self): + # Losing the bar entirely would be worse than an over-wide one. + plugin = make_plugin(fail=True) + assert plugin._progress_bar_width(TEXT_AREA, [('Song', FONT)]) == TEXT_AREA + + @pytest.mark.parametrize('area', [1, 24, 25, 100, 512]) + def test_result_always_within_bounds(self, area): + plugin = make_plugin() + width = plugin._progress_bar_width(area, [('Some Title', FONT)]) + assert 1 <= width <= area From fec7999bfdbecec48d2a966ee9aa311f56463a3e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 10:39:27 -0400 Subject: [PATCH 2/2] Address review lint on the music progress-bar tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces Cls.__new__(Cls) with a no-op-__init__ subclass for building the test shell. The __new__ form is valid Python but Codacy's Pylint reports it as a missing-cls call; the subclass reads better and states the intent — bypass the real __init__, which starts polling threads and API clients the sizing logic does not need. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ --- plugins/ledmatrix-music/test_progress_bar_width.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/plugins/ledmatrix-music/test_progress_bar_width.py b/plugins/ledmatrix-music/test_progress_bar_width.py index 2e7af6e3..927973d8 100644 --- a/plugins/ledmatrix-music/test_progress_bar_width.py +++ b/plugins/ledmatrix-music/test_progress_bar_width.py @@ -50,10 +50,19 @@ def debug(self, *a, **k): def make_plugin(config=None, px_per_char=6, fail=False): - """Build a MusicPlugin shell without running its real __init__.""" + """Build a MusicPlugin shell without running its real __init__. + + The real __init__ starts polling threads and API clients; the progress-bar + sizing needs none of that. Subclassing with an empty __init__ is clearer + than __new__ gymnastics and keeps static analysis happy. + """ from manager import MusicPlugin - plugin = MusicPlugin.__new__(MusicPlugin) + class _Shell(MusicPlugin): + def __init__(self): + pass + + plugin = _Shell() plugin.config = config if config is not None else {} plugin.display_manager = FakeDisplayManager(px_per_char, fail) plugin.logger = FakeLogger()