diff --git a/plugins.json b/plugins.json index 5d894283..977a3051 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-09-04", + "last_updated": "2026-09-07", "plugins": [ { "id": "cricket-scoreboard", @@ -215,7 +215,7 @@ "last_updated": "2026-09-03", "verified": true, "screenshot": "", - "latest_version": "1.8.7" + "latest_version": "1.8.8" }, { "id": "football-scoreboard", @@ -412,7 +412,7 @@ "last_updated": "2026-09-03", "verified": true, "screenshot": "", - "latest_version": "1.3.4" + "latest_version": "1.3.6" }, { "id": "ledmatrix-flights", @@ -463,7 +463,7 @@ "last_updated": "2026-09-03", "verified": true, "screenshot": "", - "latest_version": "1.1.1" + "latest_version": "1.1.2" }, { "id": "masters-tournament", @@ -557,7 +557,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.4.0" + "latest_version": "1.4.1" }, { "id": "on-air", @@ -605,7 +605,7 @@ "last_updated": "2026-09-03", "verified": true, "screenshot": "", - "latest_version": "1.4.4", + "latest_version": "1.4.6", "icon": "fas fa-football-ball" }, { @@ -633,7 +633,7 @@ "last_updated": "2026-09-03", "verified": true, "screenshot": "", - "latest_version": "1.4.0" + "latest_version": "1.4.2" }, { "id": "of-the-day", @@ -809,7 +809,7 @@ "last_updated": "2026-09-03", "verified": true, "screenshot": "", - "latest_version": "2.6.1" + "latest_version": "2.6.2" }, { "id": "stocks", @@ -832,7 +832,7 @@ "last_updated": "2026-09-03", "verified": true, "screenshot": "", - "latest_version": "2.9.0", + "latest_version": "2.9.1", "icon": "fas fa-chart-line" }, { @@ -856,7 +856,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.1.7" + "latest_version": "1.1.8" }, { "id": "tide-display", @@ -905,7 +905,7 @@ "last_updated": "2026-09-02", "verified": true, "screenshot": "", - "latest_version": "1.8.3", + "latest_version": "1.8.4", "icon": "fas fa-fist-raised" }, { @@ -1001,7 +1001,7 @@ "last_updated": "2026-07-31", "verified": false, "screenshot": "", - "latest_version": "1.2.3" + "latest_version": "1.2.4" }, { "id": "ledmatrix-dresden-departures", diff --git a/plugins/f1-scoreboard/manifest.json b/plugins/f1-scoreboard/manifest.json index e7ab883d..ba31eae8 100644 --- a/plugins/f1-scoreboard/manifest.json +++ b/plugins/f1-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "f1-scoreboard", "name": "F1 Scoreboard", - "version": "1.8.7", + "version": "1.8.8", "author": "ChuckBuilds", "class_name": "F1ScoreboardPlugin", "entry_point": "manager.py", diff --git a/plugins/f1-scoreboard/scroll_display.py b/plugins/f1-scoreboard/scroll_display.py index 04cca24b..d22bfbb7 100644 --- a/plugins/f1-scoreboard/scroll_display.py +++ b/plugins/f1-scoreboard/scroll_display.py @@ -18,6 +18,14 @@ "ScrollHelper not available, scrolling disabled: %s", _scroll_import_err) +try: + # Shared scroll pacing: one resolver for every plugin, so identical config + # means the same speed everywhere, and slow speeds get the frame hold that + # keeps them crisp. + from src.common import scroll_config as _scroll_config +except ImportError: # core predates the shared helper + _scroll_config = None + logger = logging.getLogger(__name__) @@ -29,6 +37,11 @@ class ScrollDisplay: and manages scroll state. """ + def _scroll_frame_hold(self) -> int: + """Refreshes to hold each frame for, from the resolved scroll settings.""" + settings = getattr(self, "_scroll_settings", None) + return getattr(settings, "frame_hold", 1) if settings else 1 + def __init__(self, display_manager, config: Optional[Dict[str, Any]] = None, custom_logger: Optional[logging.Logger] = None, global_config: Optional[Dict[str, Any]] = None): @@ -69,6 +82,19 @@ def __init__(self, display_manager, config: Optional[Dict[str, Any]] = None, buffer=self.display_width ) + # Shared resolver wins over the setup above, which stays as the + # fallback for cores that predate it. + if _scroll_config is not None: + self._scroll_settings = _scroll_config.configure( + self.scroll_helper, + plugin_config=self.config, + global_config=self.global_config, + display_manager=self.display_manager, + plugin_logger=self.logger, + ) + else: + self._scroll_settings = None + # Honor the global smooth-scrolling FPS target (older cores lack the setter) target_fps = self.global_config.get('target_fps') or self.global_config.get('scroll_target_fps') try: @@ -188,6 +214,11 @@ class ScrollDisplayManager: Manages multiple ScrollDisplay instances, one per display mode. """ + def _scroll_frame_hold(self) -> int: + """Refreshes to hold each frame for, from the resolved scroll settings.""" + settings = getattr(self, "_scroll_settings", None) + return getattr(settings, "frame_hold", 1) if settings else 1 + def __init__(self, display_manager, config: Optional[Dict[str, Any]] = None, custom_logger: Optional[logging.Logger] = None, global_config: Optional[Dict[str, Any]] = None): diff --git a/plugins/ledmatrix-elections/manager.py b/plugins/ledmatrix-elections/manager.py index 0df83631..79de6f17 100644 --- a/plugins/ledmatrix-elections/manager.py +++ b/plugins/ledmatrix-elections/manager.py @@ -17,6 +17,14 @@ from PIL import Image from src.plugin_system.base_plugin import BasePlugin +try: + # Shared scroll pacing: resolves speed from any supported config shape, + # snaps it to a speed the panel can show in whole pixels, and reports the + # frame hold that keeps slow speeds crisp. Core docs: SCROLL_PERFORMANCE.md + from src.common import scroll_config as _scroll_config +except ImportError: # core predates the shared helper + _scroll_config = None + from src.common.scroll_helper import ScrollHelper # Plugin-local imports (the plugin directory is on sys.path at load time, but be @@ -92,6 +100,19 @@ def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_man self.scroll_helper.set_dynamic_duration_settings( enabled=True, min_duration=int(self.display_duration), max_duration=300 ) + + # Shared resolver wins over the setup above, which stays as the + # fallback for cores that predate it. + if _scroll_config is not None: + self._scroll_settings = _scroll_config.configure( + self.scroll_helper, + plugin_config=self.config, + global_config=self.config.get('global', {}) or {}, + display_manager=self.display_manager, + plugin_logger=self.logger, + ) + else: + self._scroll_settings = None # Honor the global smooth-scrolling FPS target (older cores lack the setter). # The convention (news, leaderboard) is a `global` section in the plugin config. self.global_config = config.get('global', {}) or {} @@ -174,6 +195,19 @@ def on_config_change(self, new_config: dict) -> None: self.scroll_helper.set_dynamic_duration_settings( enabled=True, min_duration=int(self.display_duration), max_duration=300 ) + + # Shared resolver wins over the setup above, which stays as the + # fallback for cores that predate it. + if _scroll_config is not None: + self._scroll_settings = _scroll_config.configure( + self.scroll_helper, + plugin_config=self.config, + global_config=self.config.get('global', {}) or {}, + display_manager=self.display_manager, + plugin_logger=self.logger, + ) + else: + self._scroll_settings = None # Refresh the FPS target from the (possibly edited) global section. self.global_config = config.get('global', {}) or {} target_fps = self.global_config.get('target_fps') or self.global_config.get('scroll_target_fps', 100) @@ -199,6 +233,15 @@ def on_config_change(self, new_config: dict) -> None: # -- Update loop -------------------------------------------------------- + def _scroll_frame_hold(self) -> int: + """Refreshes to hold each frame for, from the resolved scroll settings. + + 1 without the shared helper, which is the old behaviour: a new frame + every panel refresh. + """ + settings = getattr(self, "_scroll_settings", None) + return getattr(settings, "frame_hold", 1) if settings else 1 + def update(self) -> None: now = time.time() if self._last_update and (now - self._last_update) < self.update_interval: @@ -485,7 +528,8 @@ def _display_ticker(self, force_clear: bool) -> bool: self.logger.debug("reset_scroll failed: %s", e) try: - self.display_manager.set_scrolling_state(True) + self.display_manager.set_scrolling_state( + True, frame_hold=self._scroll_frame_hold()) except Exception as e: self.logger.debug("set_scrolling_state failed: %s", e) diff --git a/plugins/ledmatrix-elections/manifest.json b/plugins/ledmatrix-elections/manifest.json index fea47ca8..2b206c9a 100644 --- a/plugins/ledmatrix-elections/manifest.json +++ b/plugins/ledmatrix-elections/manifest.json @@ -1,7 +1,7 @@ { "id": "ledmatrix-elections", "name": "Election Results", - "version": "1.2.3", + "version": "1.2.4", "author": "rpierce99", "description": "Live election results: a scrolling ticker of important races plus a full-screen interrupt when a race is newly called. Auto-activates for your state's primary + general (dormant otherwise), drains stale calls from the ticker, and survives restarts. Filterable to your state. NYT baseline provider + optional California SoS county/city rollup.", "entry_point": "manager.py", @@ -22,6 +22,12 @@ "cache_manager" ], "versions": [ + { + "released": "2026-09-07", + "version": "1.2.4", + "changelog": "Resolve scroll speed through the shared resolver and apply the frame hold it reports, so slow speeds move whole pixels", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-09-03", "version": "1.2.3", diff --git a/plugins/ledmatrix-elections/test_calendar.py b/plugins/ledmatrix-elections/test_calendar.py index 288837b1..43692dc1 100644 --- a/plugins/ledmatrix-elections/test_calendar.py +++ b/plugins/ledmatrix-elections/test_calendar.py @@ -245,7 +245,7 @@ def __init__(self): def update_display(self): pass - def set_scrolling_state(self, *_a): + def set_scrolling_state(self, *_a, **_kw): pass diff --git a/plugins/ledmatrix-leaderboard/manager.py b/plugins/ledmatrix-leaderboard/manager.py index 8a3c16b4..533d1e72 100644 --- a/plugins/ledmatrix-leaderboard/manager.py +++ b/plugins/ledmatrix-leaderboard/manager.py @@ -22,6 +22,14 @@ from PIL import Image from src.plugin_system.base_plugin import BasePlugin +try: + # Shared scroll pacing: resolves speed from any supported config shape, + # snaps it to a speed the panel can show in whole pixels, and reports the + # frame hold that keeps slow speeds crisp. Core docs: SCROLL_PERFORMANCE.md + from src.common import scroll_config as _scroll_config +except ImportError: # core predates the shared helper + _scroll_config = None + from src.common.scroll_helper import ScrollHelper from league_config import LeagueConfig @@ -140,6 +148,24 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], self.scroll_helper.target_fps = max(30.0, min(200.0, target_fps)) self.scroll_helper.frame_time_target = 1.0 / self.scroll_helper.target_fps self.logger.debug(f"Target FPS set to: {self.scroll_helper.target_fps} FPS (using fallback method)") + + # The shared resolver takes precedence over the block above, which is + # kept as the fallback for cores that predate it. Running both costs a + # few microseconds once at construction and avoids re-indenting logic + # that other config shapes still depend on. + if _scroll_config is not None: + self._scroll_settings = _scroll_config.configure( + self.scroll_helper, + plugin_config=self.config, + global_config=self.global_config, + display_manager=self.display_manager, + plugin_logger=self.logger, + ) + self.logger.info( + "Scroll pacing came from the shared resolver; any scroll speed " + "logged above this line by the legacy path was superseded") + else: + self._scroll_settings = None self.scroll_helper.set_dynamic_duration_settings( enabled=self.dynamic_duration_enabled, @@ -192,6 +218,15 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], else: self.logger.warning("No leagues are enabled - leaderboard will not display any data") + def _scroll_frame_hold(self) -> int: + """Refreshes to hold each frame for, from the resolved scroll settings. + + 1 without the shared helper, which is the old behaviour: a new frame + every panel refresh. + """ + settings = getattr(self, "_scroll_settings", None) + return getattr(settings, "frame_hold", 1) if settings else 1 + def update(self, force: bool = False) -> None: """ Update standings data for all enabled leagues. @@ -307,7 +342,8 @@ def display(self, force_clear: bool = False) -> None: return # Signal scrolling state - self.display_manager.set_scrolling_state(True) + self.display_manager.set_scrolling_state( + True, frame_hold=self._scroll_frame_hold()) self.display_manager.process_deferred_updates() # Update scroll position using the scroll helper diff --git a/plugins/ledmatrix-leaderboard/manifest.json b/plugins/ledmatrix-leaderboard/manifest.json index 995cd700..0d0e7131 100644 --- a/plugins/ledmatrix-leaderboard/manifest.json +++ b/plugins/ledmatrix-leaderboard/manifest.json @@ -1,7 +1,7 @@ { "id": "ledmatrix-leaderboard", "name": "Sports Leaderboard", - "version": "1.3.4", + "version": "1.3.6", "description": "Displays scrolling leaderboards and standings for multiple sports leagues including NFL, NBA, MLB, NCAA Football, NCAA Basketball, and more", "author": "ChuckBuilds", "entry_point": "manager.py", @@ -31,6 +31,12 @@ "requirements_file": "requirements.txt", "min_ledmatrix_version": "2.0.0", "versions": [ + { + "released": "2026-09-07", + "version": "1.3.6", + "changelog": "Resolve scroll speed through the shared resolver and apply the frame hold it reports, so slow speeds move whole pixels", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-09-03", "version": "1.3.4", diff --git a/plugins/ledmatrix-stocks/manager.py b/plugins/ledmatrix-stocks/manager.py index 73ec12e2..0de58c05 100644 --- a/plugins/ledmatrix-stocks/manager.py +++ b/plugins/ledmatrix-stocks/manager.py @@ -11,6 +11,14 @@ from src.plugin_system.base_plugin import BasePlugin +try: + # Shared scroll pacing: resolves speed from config, snaps it to a speed the + # panel can show in whole pixels, and sets the frame hold that makes slow + # speeds crisp. See docs/SCROLL_PERFORMANCE.md in the core repo. + from src.common import scroll_config as _scroll_config +except ImportError: # core predates the shared helper + _scroll_config = None + # Import our modular components from data_fetcher import StockDataFetcher from display_renderer import StockDisplayRenderer @@ -71,12 +79,6 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], # Initialize scroll helper self.scroll_helper = self.display_renderer.get_scroll_helper() - # Convert pixels per frame to pixels per second for ScrollHelper - # scroll_speed is pixels per frame, scroll_delay is seconds per frame - # pixels per second = pixels per frame / seconds per frame - pixels_per_second = self.config_manager.scroll_speed / self.config_manager.scroll_delay if self.config_manager.scroll_delay > 0 else self.config_manager.scroll_speed * 100 - self.scroll_helper.set_scroll_speed(pixels_per_second) - self.scroll_helper.set_scroll_delay(self.config_manager.scroll_delay) # Configure dynamic duration settings self.scroll_helper.set_dynamic_duration_settings( @@ -86,21 +88,59 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], buffer=self.config_manager.duration_buffer ) - # Honor the global smooth-scrolling FPS target (older cores lack the setter). - # The convention (news, leaderboard) is a `global` section in the plugin config. self.global_config = config.get('global', {}) or {} - global_config = self.global_config - target_fps = global_config.get('target_fps') or global_config.get('scroll_target_fps', 100) + self._configure_scroll(config) + + self.logger.info("Stock ticker plugin initialized - %dx%d", + self.display_width, self.display_height) + + def _scroll_frame_hold(self) -> int: + """Refreshes to hold each frame for, from the resolved scroll settings. + + 1 on cores without the shared helper, which is the old behaviour: a new + frame every refresh. + """ + settings = getattr(self, "_scroll_settings", None) + return getattr(settings, "frame_hold", 1) if settings else 1 + + def _configure_scroll(self, config): + """Apply scroll pacing via the shared helper, or the legacy path. + + The helper resolves every config shape in one place, snaps the speed to + one the panel can render in whole pixels, and sets the frame hold that + lets speeds below one pixel per refresh stay crisp. Passing + display_manager matters: without it the hold cannot be applied and slow + speeds fall back to fractional pixels. + """ + if _scroll_config is not None: + self._scroll_settings = _scroll_config.configure( + self.scroll_helper, + plugin_config=config, + global_config=self.global_config, + display_manager=self.display_manager, + plugin_logger=self.logger, + ) + return + + # Legacy path for cores without src.common.scroll_config. Kept because + # plugins update independently of the core and must not break on one + # that has not been upgraded yet. + self._scroll_settings = None + cfg = self.config_manager + pixels_per_second = (cfg.scroll_speed / cfg.scroll_delay + if cfg.scroll_delay > 0 else cfg.scroll_speed * 100) + self.scroll_helper.set_scroll_speed(pixels_per_second) + self.scroll_helper.set_scroll_delay(cfg.scroll_delay) + target_fps = (self.global_config.get('target_fps') + or self.global_config.get('scroll_target_fps', 100)) if hasattr(self.scroll_helper, 'set_target_fps'): self.scroll_helper.set_target_fps(target_fps) - self.logger.info(f"Target FPS set to: {target_fps}") else: self.scroll_helper.target_fps = max(30.0, min(200.0, target_fps)) self.scroll_helper.frame_time_target = 1.0 / self.scroll_helper.target_fps - - self.logger.info("Stock ticker plugin initialized - %dx%d", - self.display_width, self.display_height) - + self.logger.info( + "Scroll configured (legacy path): %.1f px/s", pixels_per_second) + def update(self) -> None: """Update stock and crypto data.""" current_time = time.time() @@ -147,7 +187,11 @@ def _display_scrolling(self, force_clear: bool = False) -> None: self.scroll_complete = False # Signal scrolling state - self.display_manager.set_scrolling_state(True) + # Pass the frame hold every time scrolling starts, not once at + # construction: plugins share one display manager, so a hold set at + # init is wiped as soon as any other plugin finishes its scroll. + self.display_manager.set_scrolling_state( + True, frame_hold=self._scroll_frame_hold()) # Guard for display managers that don't implement deferred updates # (e.g. the plugin-safety harness's bounds-checking display manager). if hasattr(self.display_manager, "process_deferred_updates"): diff --git a/plugins/ledmatrix-stocks/manifest.json b/plugins/ledmatrix-stocks/manifest.json index ad2cb2c2..88482fb9 100644 --- a/plugins/ledmatrix-stocks/manifest.json +++ b/plugins/ledmatrix-stocks/manifest.json @@ -1,7 +1,7 @@ { "id": "ledmatrix-stocks", "name": "Stock & Crypto Ticker", - "version": "2.9.0", + "version": "2.9.1", "description": "Displays stock tickers with prices, changes, and optional charts for stocks and cryptocurrencies. Supports scroll and switch display modes.", "author": "LEDMatrix Team", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/ledmatrix-stocks", diff --git a/plugins/march-madness/manager.py b/plugins/march-madness/manager.py index b0d2d468..594bb8cb 100644 --- a/plugins/march-madness/manager.py +++ b/plugins/march-madness/manager.py @@ -18,6 +18,14 @@ from urllib3.util.retry import Retry from src.plugin_system.base_plugin import BasePlugin +try: + # Shared scroll pacing: resolves speed from any supported config shape, + # snaps it to a speed the panel can show in whole pixels, and reports the + # frame hold that keeps slow speeds crisp. Core docs: SCROLL_PERFORMANCE.md + from src.common import scroll_config as _scroll_config +except ImportError: # core predates the shared helper + _scroll_config = None + try: from src.common.scroll_helper import ScrollHelper @@ -156,6 +164,19 @@ def __init__( else: self.scroll_helper.target_fps = max(30.0, min(200.0, self.target_fps)) self.scroll_helper.frame_time_target = 1.0 / self.scroll_helper.target_fps + + # Shared resolver wins over the setup above, which stays as the + # fallback for cores that predate it. + if _scroll_config is not None: + self._scroll_settings = _scroll_config.configure( + self.scroll_helper, + plugin_config=self.config, + global_config=self.config.get('global', {}) or {}, + display_manager=self.display_manager, + plugin_logger=self.logger, + ) + else: + self._scroll_settings = None self.scroll_helper.set_dynamic_duration_settings( enabled=self.dynamic_duration_enabled, min_duration=self.min_duration, @@ -748,6 +769,15 @@ def _create_ticker_image(self) -> None: # Plugin lifecycle # ------------------------------------------------------------------ + def _scroll_frame_hold(self) -> int: + """Refreshes to hold each frame for, from the resolved scroll settings. + + 1 without the shared helper, which is the old behaviour: a new frame + every panel refresh. + """ + settings = getattr(self, "_scroll_settings", None) + return getattr(settings, "frame_hold", 1) if settings else 1 + def update(self) -> None: """Fetch and process tournament data.""" if not self.enabled: diff --git a/plugins/march-madness/manifest.json b/plugins/march-madness/manifest.json index f4d2c9a8..4eb193ac 100644 --- a/plugins/march-madness/manifest.json +++ b/plugins/march-madness/manifest.json @@ -1,7 +1,7 @@ { "id": "march-madness", "name": "March Madness", - "version": "1.1.1", + "version": "1.1.2", "description": "NCAA March Madness tournament bracket tracker with round branding, seeded matchups, live scores, and upset highlighting", "author": "ChuckBuilds", "category": "sports", @@ -20,6 +20,12 @@ ">=2.0.0" ], "versions": [ + { + "released": "2026-09-07", + "version": "1.1.2", + "changelog": "Resolve scroll speed through the shared resolver and apply the frame hold it reports, so slow speeds move whole pixels", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-09-03", "version": "1.1.1", diff --git a/plugins/news/manager.py b/plugins/news/manager.py index 12db5316..d9a74708 100644 --- a/plugins/news/manager.py +++ b/plugins/news/manager.py @@ -30,6 +30,14 @@ from PIL import Image, ImageDraw, ImageFont from src.plugin_system.base_plugin import BasePlugin +try: + # Shared scroll pacing: resolves speed from any supported config shape, + # snaps it to a speed the panel can show in whole pixels, and reports the + # frame hold that keeps slow speeds crisp. Core docs: SCROLL_PERFORMANCE.md + from src.common import scroll_config as _scroll_config +except ImportError: # core predates the shared helper + _scroll_config = None + from src.common.scroll_helper import ScrollHelper from src.common.logo_helper import LogoHelper @@ -580,6 +588,15 @@ def _page_width_budget(self) -> Optional[float]: # would produce pages that can't hold a single headline. return max(float(self.display_width * 2), pixels_per_second * usable_seconds) + def _scroll_frame_hold(self) -> int: + """Refreshes to hold each frame for, from the resolved scroll settings. + + 1 without the shared helper, which is the old behaviour: a new frame + every panel refresh. + """ + settings = getattr(self, "_scroll_settings", None) + return getattr(settings, "frame_hold", 1) if settings else 1 + def _configure_scroll_settings(self) -> None: """ Configure scroll helper with current settings. @@ -593,6 +610,20 @@ def _configure_scroll_settings(self) -> None: # Determine if we should use frame-based scrolling # Check if scroll_pixels_per_second is None (frame-based) or set (time-based) + if _scroll_config is not None: + self._scroll_settings = _scroll_config.configure( + self.scroll_helper, + plugin_config=self.config, + global_config=self.global_config, + display_manager=self.display_manager, + plugin_logger=self.logger, + ) + return + + # Legacy path for cores without src.common.scroll_config. Plugins + # update independently of the core, so this must keep working. + self._scroll_settings = None + display_config = self.global_config.get('display', {}) use_frame_based = (self.scroll_pixels_per_second is None and display_config and @@ -1070,7 +1101,8 @@ def display(self, display_mode: str = None, force_clear: bool = False) -> None: self._cycle_complete = False # Signal scrolling state - self.display_manager.set_scrolling_state(True) + self.display_manager.set_scrolling_state( + True, frame_hold=self._scroll_frame_hold()) self.display_manager.process_deferred_updates() # Update scroll position using the scroll helper diff --git a/plugins/news/manifest.json b/plugins/news/manifest.json index c834f502..3c491f41 100644 --- a/plugins/news/manifest.json +++ b/plugins/news/manifest.json @@ -1,7 +1,7 @@ { "id": "news", "name": "News Ticker", - "version": "1.4.0", + "version": "1.4.1", "description": "Displays scrolling news headlines from RSS feeds including sports news from ESPN, NCAA updates, and custom RSS sources", "author": "ChuckBuilds", "category": "content", diff --git a/plugins/news/test_news_ticker.py b/plugins/news/test_news_ticker.py index 05429e3e..d3ebc530 100644 --- a/plugins/news/test_news_ticker.py +++ b/plugins/news/test_news_ticker.py @@ -30,7 +30,7 @@ def __init__(self, width=128, height=32): self.height = height self.image = Image.new("RGB", (width, height)) - def set_scrolling_state(self, _state): + def set_scrolling_state(self, _state, frame_hold=1): pass def process_deferred_updates(self): diff --git a/plugins/nfl-draft/manager.py b/plugins/nfl-draft/manager.py index 2b2cb9ad..272368bc 100644 --- a/plugins/nfl-draft/manager.py +++ b/plugins/nfl-draft/manager.py @@ -28,6 +28,14 @@ from PIL import Image, ImageDraw, ImageFont from src.plugin_system.base_plugin import BasePlugin +try: + # Shared scroll pacing: resolves speed from any supported config shape, + # snaps it to a speed the panel can show in whole pixels, and reports the + # frame hold that keeps slow speeds crisp. Core docs: SCROLL_PERFORMANCE.md + from src.common import scroll_config as _scroll_config +except ImportError: # core predates the shared helper + _scroll_config = None + from src.common.scroll_helper import ScrollHelper from src.common.logo_helper import LogoHelper from src.common.api_helper import APIHelper @@ -132,6 +140,19 @@ def _load_config(self) -> None: if hasattr(self.scroll_helper, 'set_scroll_delay'): self.scroll_helper.set_scroll_delay(self.scroll_delay) + # Shared resolver wins over the setup above, which stays as the + # fallback for cores that predate it. + if _scroll_config is not None: + self._scroll_settings = _scroll_config.configure( + self.scroll_helper, + plugin_config=self.config, + global_config=self.config.get('global', {}) or {}, + display_manager=self.display_manager, + plugin_logger=self.logger, + ) + else: + self._scroll_settings = None + # Honor the global smooth-scrolling FPS target (older cores lack the setter). # The convention (news, leaderboard) is a `global` section in the plugin config. self.global_config = self.config.get('global', {}) or {} @@ -1126,6 +1147,15 @@ def _load_nfl_draft_logo(self) -> Optional[Image.Image]: self.logger.error(f"Error loading NFL Draft logo: {e}") return None + def _scroll_frame_hold(self) -> int: + """Refreshes to hold each frame for, from the resolved scroll settings. + + 1 without the shared helper, which is the old behaviour: a new frame + every panel refresh. + """ + settings = getattr(self, "_scroll_settings", None) + return getattr(settings, "frame_hold", 1) if settings else 1 + def update(self) -> None: """ Fetch/update draft data from ESPN API. diff --git a/plugins/nfl-draft/manifest.json b/plugins/nfl-draft/manifest.json index 884bb2ec..0068e377 100644 --- a/plugins/nfl-draft/manifest.json +++ b/plugins/nfl-draft/manifest.json @@ -1,7 +1,7 @@ { "id": "nfl-draft", "name": "NFL Draft", - "version": "1.4.4", + "version": "1.4.6", "author": "ChuckBuilds", "description": "Displays projected NFL draft picks from ESPN with live draft tracking support during the annual NFL Draft event. Includes simulate_live mode to replay a completed draft using real ESPN core API data. Shows team logos, player names, positions, and pick numbers in a scrolling display.", "entry_point": "manager.py", @@ -24,6 +24,12 @@ ], "icon": "fas fa-football-ball", "versions": [ + { + "released": "2026-09-07", + "version": "1.4.6", + "changelog": "Resolve scroll speed through the shared resolver and apply the frame hold it reports, so slow speeds move whole pixels", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-09-03", "version": "1.4.4", diff --git a/plugins/odds-ticker/manager.py b/plugins/odds-ticker/manager.py index 4ded99c4..d975df42 100644 --- a/plugins/odds-ticker/manager.py +++ b/plugins/odds-ticker/manager.py @@ -66,6 +66,14 @@ def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_man self.cache_manager = cache_manager self.plugin_manager = plugin_manager +try: + # Shared scroll pacing: resolves speed from any supported config + # shape, snaps it to a speed the panel can show in whole pixels, and + # reports the frame hold that keeps slow speeds crisp. + from src.common import scroll_config as _scroll_config +except ImportError: # core predates the shared helper + _scroll_config = None + # Import BaseOddsManager from LEDMatrix core try: from src.base_odds_manager import BaseOddsManager @@ -247,7 +255,14 @@ def get_config(section, key, default, old_key=None): # Priority 1: Current format - use display_options object self.scroll_speed = display_options.get('scroll_speed', 1.0) self.scroll_delay = display_options.get('scroll_delay', 0.02) - self.scroll_pixels_per_second = display_options.get('scroll_pixels_per_second') + # Must be None here, exactly as the display_config branch below + # does it. config_schema.json gives this deprecated key a default + # of 50.0, and schema defaults are merged into plugin config, so + # reading it on this path left it permanently non-None and + # use_frame_based below could never be True -- the documented + # scroll_speed/scroll_delay settings were unreachable for every + # user. See issue #408. + self.scroll_pixels_per_second = None self.logger.info(f"Using display_options.scroll_speed={self.scroll_speed} px/frame, display_options.scroll_delay={self.scroll_delay}s (frame-based mode)") elif display_config and ('scroll_speed' in display_config or 'scroll_delay' in display_config): # Old nested format: use display object for granular control @@ -328,9 +343,19 @@ def get_config(section, key, default, old_key=None): # Configure ScrollHelper with plugin settings # Check if we should use frame-based scrolling (new format) or time-based (old format) - use_frame_based = (self.scroll_pixels_per_second is None and - display_config and - ('scroll_speed' in display_config or 'scroll_delay' in display_config)) + # Accept either config shape. This previously consulted only + # display_config (the deprecated "display" block), so even with the + # fix above the recommended display_options format could never select + # frame-based mode. Both halves of #408 are needed. + use_frame_based = ( + self.scroll_pixels_per_second is None + and ( + (display_options and ('scroll_speed' in display_options + or 'scroll_delay' in display_options)) + or (display_config and ('scroll_speed' in display_config + or 'scroll_delay' in display_config)) + ) + ) if use_frame_based: # New format: use frame-based scrolling for finer control @@ -366,6 +391,24 @@ def get_config(section, key, default, old_key=None): self.scroll_helper.target_fps = max(30.0, min(200.0, self.target_fps)) self.scroll_helper.frame_time_target = 1.0 / self.scroll_helper.target_fps self.logger.debug(f"Target FPS set to: {self.scroll_helper.target_fps} FPS (using fallback method)") + + # The shared resolver takes precedence over the block above, which is + # kept as the fallback for cores that predate it. Running both costs a + # few microseconds once at construction and avoids re-indenting logic + # that other config shapes still depend on. + if _scroll_config is not None: + self._scroll_settings = _scroll_config.configure( + self.scroll_helper, + plugin_config=self.config, + global_config=self.global_config, + display_manager=self.display_manager, + plugin_logger=self.logger, + ) + self.logger.info( + "Scroll pacing came from the shared resolver; any scroll speed " + "logged above this line by the legacy path was superseded") + else: + self._scroll_settings = None self.scroll_helper.set_dynamic_duration_settings( enabled=self.dynamic_duration_enabled, min_duration=self.min_duration, @@ -2602,6 +2645,15 @@ def on_config_change(self, new_config: Dict[str, Any]) -> None: self.show_channel_logos = new_show_logos self.logger.info(f"Show channel logos updated to: {self.show_channel_logos}") + def _scroll_frame_hold(self) -> int: + """Refreshes to hold each frame for, from the resolved scroll settings. + + 1 without the shared helper, which is the old behaviour: a new frame + every panel refresh. + """ + settings = getattr(self, "_scroll_settings", None) + return getattr(settings, "frame_hold", 1) if settings else 1 + def update(self): """Update odds ticker data.""" logger.debug("Entering update method") @@ -2695,7 +2747,25 @@ def _has_games_starting_soon(self) -> bool: return True return False + #: How long a computed update interval is reused for. display() asks every + #: frame, and the slow path below reads the scoreboard cache from disk and + #: parses JSON per enabled league -- on the render thread. That produced a + #: single ~15ms frame every few minutes, visible as a hitch mid-scroll. + #: 15s keeps live detection responsive; the scoreboard re-check underneath + #: is rate limited to _live_check_interval (300s) regardless. + _INTERVAL_CACHE_SECONDS = 15.0 + def _get_current_update_interval(self) -> int: + """The current update interval, memoised off the render path.""" + now = time.time() + cached = getattr(self, "_interval_cache", None) + if cached is not None and (now - cached[0]) < self._INTERVAL_CACHE_SECONDS: + return cached[1] + value = self._compute_update_interval() + self._interval_cache = (now, value) + return value + + def _compute_update_interval(self) -> int: """Get the current update interval based on game status. - Live games: use live_game_update_interval (default 60s) @@ -2720,7 +2790,14 @@ def _perform_update(self, preserve_scroll: bool = False): # Dynamically determine update interval based on live games current_interval = self._get_current_update_interval() if current_time - self.last_update < current_interval: - logger.debug(f"Odds ticker update interval not reached. Next update in {current_interval - (current_time - self.last_update)} seconds (interval: {current_interval}s, live games: {self._has_live_games()})") + # %s args, not an f-string: the f-string called _has_live_games() + # on every skipped update even with debug logging off, which is a + # scoreboard cache read for the sake of a message nobody sees. + logger.debug( + "Odds ticker update interval not reached. Next update in %.0f " + "seconds (interval: %ss)", + current_interval - (current_time - self.last_update), + current_interval) return # Use lock to prevent concurrent modifications during live updates @@ -2947,7 +3024,8 @@ def create_image(): # Signal scrolling state if hasattr(self.display_manager, 'set_scrolling_state'): if self.loop or not self.scroll_helper.is_scroll_complete(): - self.display_manager.set_scrolling_state(True) + self.display_manager.set_scrolling_state( + True, frame_hold=self._scroll_frame_hold()) else: self.display_manager.set_scrolling_state(False) diff --git a/plugins/odds-ticker/manifest.json b/plugins/odds-ticker/manifest.json index 1adc2b2e..9818fbf7 100644 --- a/plugins/odds-ticker/manifest.json +++ b/plugins/odds-ticker/manifest.json @@ -1,7 +1,7 @@ { "id": "odds-ticker", "name": "Odds Ticker", - "version": "1.4.0", + "version": "1.4.2", "description": "Displays scrolling odds and betting lines for upcoming games across multiple sports leagues including NFL, NBA, MLB, NCAA Football, and more", "author": "ChuckBuilds", "category": "sports", diff --git a/plugins/odds-ticker/test_scroll_cache_invalidation.py b/plugins/odds-ticker/test_scroll_cache_invalidation.py index 139a7fca..96078af9 100644 --- a/plugins/odds-ticker/test_scroll_cache_invalidation.py +++ b/plugins/odds-ticker/test_scroll_cache_invalidation.py @@ -69,8 +69,12 @@ def __init__(self): def update_display(self): self.updated += 1 - def set_scrolling_state(self, state): - pass + def set_scrolling_state(self, is_scrolling, frame_hold=1): + # frame_hold mirrors DisplayManager; display() passes it now, and a + # double that cannot take it raises TypeError mid-render, so no + # frame reaches the display and this file's real assertions fail + # for a reason that has nothing to do with cache invalidation. + self.frame_hold = frame_hold class _Ticker: @@ -79,6 +83,10 @@ class _Ticker: # The methods under test, unmodified. display = OddsTickerPlugin.display _create_ticker_image = OddsTickerPlugin._create_ticker_image + # display() reads the resolved scroll pacing through this. Borrowed rather + # than stubbed so it stays honest: it returns 1 when _scroll_settings is + # absent, which is what this double wants anyway. + _scroll_frame_hold = OddsTickerPlugin._scroll_frame_hold def __init__(self): self.is_enabled = True diff --git a/plugins/stock-news/manager.py b/plugins/stock-news/manager.py index 60b7888e..b86b0220 100644 --- a/plugins/stock-news/manager.py +++ b/plugins/stock-news/manager.py @@ -30,6 +30,14 @@ import re from datetime import datetime from pathlib import Path +try: + # Shared scroll pacing: resolves speed from any supported config shape, + # snaps it to a speed the panel can show in whole pixels, and reports the + # frame hold that keeps slow speeds crisp. Core docs: SCROLL_PERFORMANCE.md + from src.common import scroll_config as _scroll_config +except ImportError: # core predates the shared helper + _scroll_config = None + from typing import Dict, Any, List, Optional, Tuple from PIL import Image, ImageDraw, ImageFont from requests.adapters import HTTPAdapter @@ -310,8 +318,36 @@ def _load_fonts(self) -> dict: return {'headline': main_font, 'symbol': main_font, 'publisher': publisher_font, 'age': age_font} + def _scroll_frame_hold(self) -> int: + """Refreshes to hold each frame for, from the resolved scroll settings. + + 1 without the shared helper, which is the old behaviour: a new frame + every panel refresh. + """ + settings = getattr(self, "_scroll_settings", None) + return getattr(settings, "frame_hold", 1) if settings else 1 + def _configure_scroll_settings(self) -> None: - """Apply scroll speed / FPS to ScrollHelper using frame-based scrolling.""" + """Apply scroll speed / FPS to the ScrollHelper. + + Prefers the shared resolver so this plugin agrees with every other one + about what a given config means, and so slow speeds get the frame hold + that keeps them crisp. + """ + if _scroll_config is not None: + self._scroll_settings = _scroll_config.configure( + self.scroll_helper, + plugin_config=self.config, + global_config=self.global_config, + display_manager=self.display_manager, + plugin_logger=self.logger, + ) + return + + # Legacy path for cores without src.common.scroll_config. Plugins + # update independently of the core, so this must keep working. + self._scroll_settings = None + if 'scroll_pixels_per_second' in self.global_config: pps = float(self.global_config['scroll_pixels_per_second']) elif self.scroll_delay and self.scroll_delay > 0: @@ -816,7 +852,8 @@ def display(self, display_mode: Optional[str] = None, force_clear: bool = False) self.scroll_helper.reset_scroll() self._cycle_complete = False - self.display_manager.set_scrolling_state(True) + self.display_manager.set_scrolling_state( + True, frame_hold=self._scroll_frame_hold()) self.display_manager.process_deferred_updates() self.scroll_helper.update_scroll_position() diff --git a/plugins/stock-news/manifest.json b/plugins/stock-news/manifest.json index 3105aa51..e20cfbdf 100644 --- a/plugins/stock-news/manifest.json +++ b/plugins/stock-news/manifest.json @@ -1,7 +1,7 @@ { "id": "stock-news", "name": "Stock News Ticker", - "version": "2.6.1", + "version": "2.6.2", "author": "ChuckBuilds", "description": "Live stock headlines via Yahoo Finance search API with RSS fallback, company logos, configurable display styles (logo+ticker, ticker only, logo only), Vegas scroll integration, and per-day/hour request budgeting", "entry_point": "manager.py", diff --git a/plugins/text-display/manager.py b/plugins/text-display/manager.py index b5d4b547..50cf62d4 100644 --- a/plugins/text-display/manager.py +++ b/plugins/text-display/manager.py @@ -22,6 +22,14 @@ from pathlib import Path from src.plugin_system.base_plugin import BasePlugin +try: + # Shared scroll pacing: resolves speed from any supported config shape, + # snaps it to a speed the panel can show in whole pixels, and reports the + # frame hold that keeps slow speeds crisp. Core docs: SCROLL_PERFORMANCE.md + from src.common import scroll_config as _scroll_config +except ImportError: # core predates the shared helper + _scroll_config = None + from src.common.scroll_helper import ScrollHelper try: @@ -177,6 +185,24 @@ def __init__(self, plugin_id: str, config: Dict[str, Any], # Calculate pixels per second for logging (even though we use frame-based mode) pixels_per_second = self.scroll_speed / self.scroll_delay if self.scroll_delay > 0 else self.scroll_speed * 100 self.logger.info(f"Scroll settings: {self.scroll_speed} px/frame, {self.scroll_delay}s delay = {pixels_per_second:.1f} px/s, target FPS: {self.target_fps}") + + # The shared resolver takes precedence over the block above, which is + # kept as the fallback for cores that predate it. Running both costs a + # few microseconds once at construction and avoids re-indenting logic + # that other config shapes still depend on. + if _scroll_config is not None: + self._scroll_settings = _scroll_config.configure( + self.scroll_helper, + plugin_config=self.config, + global_config=self.global_config, + display_manager=self.display_manager, + plugin_logger=self.logger, + ) + self.logger.info( + "Scroll pacing came from the shared resolver; any scroll speed " + "logged above this line by the legacy path was superseded") + else: + self._scroll_settings = None self.scroll_helper.set_dynamic_duration_settings( enabled=True, # Honor the documented display_duration setting as the on-screen floor. @@ -419,6 +445,15 @@ def _create_text_cache(self): self.logger.error(f"Failed to create text cache: {e}", exc_info=True) self.text_image_cache = None + def _scroll_frame_hold(self) -> int: + """Refreshes to hold each frame for, from the resolved scroll settings. + + 1 without the shared helper, which is the old behaviour: a new frame + every panel refresh. + """ + settings = getattr(self, "_scroll_settings", None) + return getattr(settings, "frame_hold", 1) if settings else 1 + def update(self) -> None: """Update scroll position if scrolling is enabled using ScrollHelper.""" if not self.scroll_enabled or self.text_width <= self.display_manager.matrix.width: @@ -505,7 +540,8 @@ def display(self, force_clear: bool = False) -> None: if hasattr(self.display_manager, 'set_scrolling_state'): # Only signal scrolling if not complete (or if looping and will reset) if not self.scroll_helper.is_scroll_complete() or (self.scroll_loop and self.scroll_helper.is_scroll_complete()): - self.display_manager.set_scrolling_state(True) + self.display_manager.set_scrolling_state( + True, frame_hold=self._scroll_frame_hold()) else: # One-shot mode and complete - stop scrolling self.display_manager.set_scrolling_state(False) diff --git a/plugins/text-display/manifest.json b/plugins/text-display/manifest.json index 62640934..881ceb6f 100644 --- a/plugins/text-display/manifest.json +++ b/plugins/text-display/manifest.json @@ -1,7 +1,7 @@ { "id": "text-display", "name": "Text Display", - "version": "1.1.7", + "version": "1.1.8", "author": "ChuckBuilds", "description": "Display custom scrolling or static text with configurable fonts, colors, and scroll speed. Perfect for announcements, messages, or custom displays.", "category": "display", @@ -24,6 +24,12 @@ } }, "versions": [ + { + "released": "2026-09-07", + "version": "1.1.8", + "changelog": "Resolve scroll speed through the shared resolver and apply the frame hold it reports, so slow speeds move whole pixels", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-09-03", "version": "1.1.7", diff --git a/plugins/ufc-scoreboard/manifest.json b/plugins/ufc-scoreboard/manifest.json index 75adfd95..14ed3228 100644 --- a/plugins/ufc-scoreboard/manifest.json +++ b/plugins/ufc-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "ufc-scoreboard", "name": "UFC Scoreboard", - "version": "1.8.3", + "version": "1.8.4", "author": "LegoGuy1000", "contributors": [ { @@ -32,6 +32,12 @@ "default_duration": 15, "config_schema": "config_schema.json", "versions": [ + { + "released": "2026-09-07", + "version": "1.8.4", + "changelog": "Resolve scroll speed through the shared resolver and apply the frame hold it reports, so slow speeds move whole pixels", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-09-04", "version": "1.8.3", diff --git a/plugins/ufc-scoreboard/scroll_display.py b/plugins/ufc-scoreboard/scroll_display.py index 55cc0f83..78a609bd 100644 --- a/plugins/ufc-scoreboard/scroll_display.py +++ b/plugins/ufc-scoreboard/scroll_display.py @@ -22,6 +22,14 @@ except ImportError: ScrollHelper = None +try: + # Shared scroll pacing: one resolver for every plugin, so identical config + # means the same speed everywhere, and slow speeds get the frame hold that + # keeps them crisp. + from src.common import scroll_config as _scroll_config +except ImportError: # core predates the shared helper + _scroll_config = None + from fight_renderer import FightRenderer logger = logging.getLogger(__name__) @@ -42,6 +50,11 @@ class ScrollDisplayManager: # Path to UFC separator icon UFC_SEPARATOR_ICON = "assets/sports/ufc_logos/UFC.png" + def _scroll_frame_hold(self) -> int: + """Refreshes to hold each frame for, from the resolved scroll settings.""" + settings = getattr(self, "_scroll_settings", None) + return getattr(settings, "frame_hold", 1) if settings else 1 + def __init__( self, display_manager, @@ -144,6 +157,19 @@ def _configure_scroll_helper(self) -> None: pixels_per_frame = max(0.1, min(5.0, pixels_per_frame)) self.scroll_helper.set_scroll_speed(pixels_per_frame) + # Shared resolver wins over the setup above, which stays as the + # fallback for cores that predate it. + if _scroll_config is not None: + self._scroll_settings = _scroll_config.configure( + self.scroll_helper, + plugin_config=self.config, + global_config=self.global_config, + display_manager=self.display_manager, + plugin_logger=self.logger, + ) + else: + self._scroll_settings = None + effective_pps = pixels_per_frame / scroll_delay if scroll_delay > 0 else pixels_per_frame * 100 self.logger.info( diff --git a/scripts/test_scroll_state_doubles.py b/scripts/test_scroll_state_doubles.py new file mode 100644 index 00000000..a3aec1b6 --- /dev/null +++ b/scripts/test_scroll_state_doubles.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""A display-manager double must accept every argument the plugins pass it. + +`DisplayManager.set_scrolling_state` gained a `frame_hold` argument, and the +plugins that pace their scroll now pass it. A test double still declaring +`set_scrolling_state(self, state)` raises TypeError the moment display() runs, +so the test fails for a reason that has nothing to do with what it asserts -- +and the traceback is swallowed by the plugin's own `except Exception` in +display(), leaving only a bare "no frame reached the display". + +Three doubles in this repo were behind at once (odds-ticker, news, +ledmatrix-elections) and CI caught exactly one of them, because the other two +never reach the scrolling branch. That is why this is a check and not a +convention. + +Run: python scripts/test_scroll_state_doubles.py +""" + +import ast +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + + +def accepts_frame_hold(fn: ast.FunctionDef) -> bool: + """True if this def can take frame_hold, positionally or by keyword.""" + named = [a.arg for a in fn.args.args] + [a.arg for a in fn.args.kwonlyargs] + if "frame_hold" in named: + return True + if fn.args.kwarg is not None: # **kwargs absorbs it + return True + # *args alone does NOT: the call passes frame_hold as a keyword. + return False + + +def check(path: Path) -> list: + try: + tree = ast.parse(path.read_text(encoding="utf-8")) + except (OSError, SyntaxError): + return [] + bad = [] + for node in ast.walk(tree): + if (isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name == "set_scrolling_state" + and not accepts_frame_hold(node)): + bad.append((node.lineno, ast.unparse(node.args))) + return bad + + +def main() -> int: + files = sorted(ROOT.glob("plugins/**/*.py")) + sorted(ROOT.glob("scripts/*.py")) + failures = [] + for path in files: + for lineno, sig in check(path): + failures.append(f"{path.relative_to(ROOT)}:{lineno}: set_scrolling_state({sig})") + + if failures: + print("A set_scrolling_state double cannot accept frame_hold.") + print("Plugins pass it as a keyword, so these raise TypeError at render time:\n") + for f in failures: + print(f" {f}") + print("\nAdd `frame_hold=1` to the signature (or accept **kwargs).") + return 1 + + print(f"OK: every set_scrolling_state double accepts frame_hold " + f"({len(files)} files checked)") + return 0 + + +if __name__ == "__main__": + sys.exit(main())