feat(vegas): keep live games in the ticker by default

display.vegas_scroll.live_in_ticker now defaults to true: through a live
game the marquee keeps running and the live scoreboard takes extra turns in
it -- its cards updating in place while they scroll -- instead of the ticker
giving way to the full-screen scoreboard.

The new default would reach nobody on its own: every existing config holds
an explicit false copied from the template (there was no control for it),
and the template merge only adds missing keys. ConfigManager therefore turns
a stored false on once, with a backup, and records live_in_ticker_migrated
so a false chosen afterwards stays. The marker is never in the template.

A "Keep live games in the ticker" checkbox under Vegas mode sets it. Tests
that pin the full-screen takeover now say live_in_ticker=false.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-30 18:36:39 -04:00
co-authored by Claude Opus 5.5
parent 8cce532ea9
commit 559ef14a5c
16 changed files with 246 additions and 30 deletions
+9
View File
@@ -406,6 +406,15 @@ read any of them:
`render_vegas_strip()` and `render_vegas_timeline()` in
`src/plugin_system/testing/vegas.py`; the join is now
`render_pipeline.join_plugin_rows()`.
- **Behaviour change: live games stay in the Vegas ticker by default.**
`display.vegas_scroll.live_in_ticker` now defaults to `true`: the marquee
keeps running through a live game, which takes extra turns in it, instead
of giving way to the full-screen scoreboard. Existing configs all held the
old `false`, copied from the template, so the first start turns it on once
(`ConfigManager._migrate_live_in_ticker_default`; the previous config is
kept as `config.json.backup` and `live_in_ticker_migrated` records that it
ran). To keep the full-screen scoreboard, untick the new **Keep live games
in the ticker** under Vegas mode; a `false` set after the migration stays.
### Scrolling
+1 -1
View File
@@ -134,7 +134,7 @@
"plugin_rotation_order": [],
"use_short_date_format": true,
"vegas_scroll": {
"live_in_ticker": false,
"live_in_ticker": true,
"live_weight": 3,
"favorite_live_weight": 5,
"enabled": false,
+21 -7
View File
@@ -75,17 +75,31 @@ total. See the full list in
### Live Content in the Ticker
By default, live content **preempts** Vegas mode: while any plugin reports
live priority, the display controller refuses to run the ticker and shows
that plugin's full-screen display instead. You get a big readable scoreboard,
but the marquee stops entirely for the duration of the game.
By default (since 3.8.0) live content **stays in the ticker** and takes
**extra turns inside it**, and a scoreboard that supports live cards updates
the score on a card already crossing the screen (`live_refresh`, "Update live
content while it scrolls").
Set `live_in_ticker` to keep the ticker running and let live content take
**extra turns inside it** instead:
To get the old behaviour back -- live content **preempts** Vegas mode: while
any plugin reports live priority the ticker stops and that plugin's
full-screen display is shown instead -- untick **Keep live games in the
ticker** under Vegas mode, or set `live_in_ticker` to `false`:
```json
"vegas_scroll": {
"live_in_ticker": false
}
```
Until 3.8.0 `false` was the default and every config held it, copied from
the template. The first start on 3.8.0 turns it on once (a backup of the
config is kept as `config.json.backup`, and `live_in_ticker_migrated` records
that it ran); a `false` set after that is left alone.
The weights below apply while live content is in the ticker:
```json
"vegas_scroll": {
"live_in_ticker": true,
"live_weight": 3,
"favorite_live_weight": 5
}
+1 -1
View File
@@ -152,7 +152,7 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
| `max_cycle_duration` | int, `240` |
| `frame_based_scrolling` | bool, `true` — does not step or set a frame rate; motion is by elapsed time either way. When `true`, `scroll_speed` passes through a clamp of 0.1–5 px per `scroll_delay` (see next row) |
| `scroll_delay` | float, `0.02` — not a frame period. Only used with `frame_based_scrolling`: the applied speed is `clamp(scroll_speed × scroll_delay, 0.1, 5) / scroll_delay` px/s, so at `0.02` speeds under 5 px/s run at 5, and at `0.001` nothing runs slower than 100 px/s |
| `live_in_ticker` | bool, `false` — keep scrolling during live games instead of handing the display to a full-screen scoreboard |
| `live_in_ticker` | bool, `true` — keep scrolling during live games instead of handing the display to a full-screen scoreboard. `false` was the default before 3.8.0; the first start on 3.8.0 turns a stored `false` on once and sets `live_in_ticker_migrated` |
| `live_weight` | int, `3` (1–10) — slots per cycle for a plugin with live content |
| `favorite_live_weight` | int, `5` (1–10) — slots per cycle when a plugin reports a favorite team is live |
+3
View File
@@ -336,6 +336,9 @@ to rebuild it twice, 1.7-3.8 ms each on a Pi 4). The `extend` row of
work" table), and extensions no longer rebuilding the strip's PIL copy.
3. **Live elements:** the plugin API, the records, the worker and in-place
patches, with the sports scoreboards and the flight map adopting it.
4. **Live games in the ticker by default:** `live_in_ticker` true, so a live
game's cards update in the marquee instead of the full-screen scoreboard
replacing it; existing configs are switched once (`ConfigManager`).
`display.vegas_scroll.offscreen_prefetch` (default `true`) restores the
deferred path when `false`, and `display.vegas_scroll.live_refresh` (default
+3 -3
View File
@@ -308,9 +308,9 @@ the core then falls back to its own live-content check — so a plugin whose
weight calculation is broken still gets `live_weight` for a game that really
is live, rather than being demoted to 1.
Only consulted when the user has set `vegas_scroll.live_in_ticker`. With the
default (`false`) live content preempts Vegas entirely and there is no ticker
to be weighted within. See
Only consulted while `vegas_scroll.live_in_ticker` is on (the default since
3.8.0). With it off live content preempts Vegas entirely and there is no
ticker to be weighted within. See
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md#live-content-in-the-ticker).
### Vegas scroll hooks
+45 -3
View File
@@ -451,8 +451,10 @@ class ConfigManager:
template_config = json.load(f)
# Check if migration is needed
if self._config_needs_migration(self.config, template_config):
self.logger.info("Config migration needed - adding new configuration items with defaults")
needs_merge = self._config_needs_migration(self.config, template_config)
if needs_merge or self._live_in_ticker_needs_migration():
if needs_merge:
self.logger.info("Config migration needed - adding new configuration items with defaults")
# Create backup of current config
backup_path = f"{self.config_path}.backup"
@@ -461,7 +463,9 @@ class ConfigManager:
self.logger.info(f"Created backup of current config at {os.path.abspath(backup_path)}")
# Merge template defaults into current config
self._merge_template_defaults(self.config, template_config)
if needs_merge:
self._merge_template_defaults(self.config, template_config)
self._migrate_live_in_ticker_default()
# save_config_atomic strips the merged secrets back out and
# keeps the file's owner and mode.
@@ -482,6 +486,44 @@ class ConfigManager:
self.logger.error(f"Error during config migration: {e}")
# Don't raise - continue with current config
#: Set in display.vegas_scroll once _migrate_live_in_ticker_default() has
#: run. Never in the template: the template merge would add it first, and
#: the flip would then never run.
LIVE_IN_TICKER_MARKER = 'live_in_ticker_migrated'
def _vegas_scroll_section(self) -> Optional[Dict[str, Any]]:
display = self.config.get('display')
vegas = display.get('vegas_scroll') if isinstance(display, dict) else None
return vegas if isinstance(vegas, dict) else None
def _live_in_ticker_needs_migration(self) -> bool:
vegas = self._vegas_scroll_section()
return vegas is not None and not vegas.get(self.LIVE_IN_TICKER_MARKER)
def _migrate_live_in_ticker_default(self) -> None:
"""Turn on live_in_ticker for a config that only ever had the old default. Once.
LEDMatrix 3.8.0 makes ``display.vegas_scroll.live_in_ticker`` true:
live games stay in the Vegas ticker, their cards updating while they
scroll, instead of the ticker giving way to the full-screen
scoreboard. Every existing config holds an explicit ``false`` copied
from the template -- there was no control for it -- and the template
merge only adds missing keys, so the new default would reach nobody.
This rewrites that ``false`` once and marks the config, so a
``false`` chosen afterwards (the Vegas checkbox, or by hand) stays.
"""
vegas = self._vegas_scroll_section()
if vegas is None or vegas.get(self.LIVE_IN_TICKER_MARKER):
return
vegas[self.LIVE_IN_TICKER_MARKER] = True
if vegas.get('live_in_ticker') is False:
vegas['live_in_ticker'] = True
self.logger.info(
"Vegas mode now keeps live games in the ticker (the new default): "
"display.vegas_scroll.live_in_ticker turned on, once. Untick "
"\"Keep live games in the ticker\" under Vegas mode for the "
"full-screen scoreboard.")
def _config_needs_migration(self, current_config: Dict[str, Any], template_config: Dict[str, Any]) -> bool:
"""Check if config needs migration by comparing with template."""
return self._has_new_keys(current_config, template_config)
+1 -1
View File
@@ -2206,7 +2206,7 @@ class DisplayController:
"""Whether live content should stay in the ticker instead of preempting it."""
coordinator = self.vegas_coordinator
config = getattr(coordinator, 'vegas_config', None)
return bool(getattr(config, 'live_in_ticker', False))
return bool(getattr(config, 'live_in_ticker', True))
def _check_live_priority(self, advance=False):
"""Return the live-priority mode to display, or None if nothing is live.
+8 -5
View File
@@ -180,10 +180,13 @@ class VegasModeConfig:
# --- Live content in the ticker -------------------------------------
#
# By default a live game preempts Vegas entirely: the display controller
# refuses to run the ticker while any plugin reports live priority, and you
# get the full-screen scoreboard instead. Set live_in_ticker to keep the
# marquee running and let live content take extra turns within it.
# By default live content stays in the marquee and takes extra turns
# within it, its cards updating while they scroll (live elements, below).
# With live_in_ticker false a live game preempts Vegas entirely: the
# display controller refuses to run the ticker while any plugin reports
# live priority, and you get the full-screen scoreboard instead. (False
# was the default until 3.8.0; ConfigManager turns it on once for configs
# that still hold the old default.)
#
# The rotation is otherwise a strict round robin -- every plugin appears
# exactly once per cycle -- so with a dozen plugins enabled a live score
@@ -193,7 +196,7 @@ class VegasModeConfig:
# Weights are per plugin, not per game: a scoreboard showing four live
# games still occupies one slot at a time, and rotates its own games within
# that slot using its own favorite_live_boost.
live_in_ticker: bool = False
live_in_ticker: bool = True
# Slots per cycle for a plugin reporting live content. 1 disables the boost
# and restores the plain round robin.
+14
View File
@@ -134,6 +134,20 @@ class TestJsonPartialSaves:
assert resp.status_code == 200, resp.get_json()
assert saved['config']['display']['vegas_scroll']['live_refresh'] is True
def test_live_games_can_be_kept_in_the_ticker_or_not(self, api_v3_client, saved):
resp = _post_json(api_v3_client, {'vegas_live_in_ticker': False})
assert resp.status_code == 200, resp.get_json()
assert saved['config']['display']['vegas_scroll']['live_in_ticker'] is False
# An unticked box is absent from a submitted form: that is false too.
resp = _post_json(api_v3_client, {'__form_section': 'display',
'vegas_scroll_speed': '50',
'vegas_live_in_ticker': 'on'})
assert saved['config']['display']['vegas_scroll']['live_in_ticker'] is True
resp = _post_json(api_v3_client, {'__form_section': 'display',
'vegas_scroll_speed': '50'})
assert resp.status_code == 200, resp.get_json()
assert saved['config']['display']['vegas_scroll']['live_in_ticker'] is False
def test_double_sided_axis_only_keeps_enabled(self, api_v3_client, saved):
resp = _post_json(api_v3_client, {'double_sided_axis': 'horizontal'})
assert resp.status_code == 200, resp.get_json()
@@ -0,0 +1,108 @@
"""live_in_ticker's new default reaches existing installs, once (src/config_manager.py).
3.8.0 makes display.vegas_scroll.live_in_ticker true. Every existing config
holds an explicit false copied from the template, which the template merge
never touches (it only adds missing keys), so ConfigManager turns that false
on once and marks the config. A false chosen after that -- the new checkbox,
or by hand -- must stay false.
"""
import json
import os
import sys
from pathlib import Path
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from src.config_manager import ConfigManager # noqa: E402
REPO = Path(__file__).resolve().parent.parent
MARKER = ConfigManager.LIVE_IN_TICKER_MARKER
@pytest.fixture
def files(tmp_path):
template = {"display": {"brightness": 90,
"vegas_scroll": {"enabled": False, "live_in_ticker": True}}}
paths = {name: tmp_path / f"{name}.json" for name in ("config", "secrets", "template")}
paths["template"].write_text(json.dumps(template))
paths["secrets"].write_text("{}")
return paths
def _load(files, config):
files["config"].write_text(json.dumps(config))
manager = ConfigManager(config_path=str(files["config"]),
secrets_path=str(files["secrets"]))
manager.template_path = str(files["template"])
manager.load_config()
return manager, json.loads(files["config"].read_text())
def _vegas(saved):
return saved["display"]["vegas_scroll"]
def test_the_old_default_is_turned_on_once_and_marked(files):
old = {"display": {"brightness": 90,
"vegas_scroll": {"enabled": True, "live_in_ticker": False}}}
manager, saved = _load(files, old)
assert _vegas(saved)["live_in_ticker"] is True
assert _vegas(saved)[MARKER] is True
assert _vegas(manager.config)["live_in_ticker"] is True
# The config as it was is kept beside it.
backup = json.loads(Path(f"{files['config']}.backup").read_text())
assert _vegas(backup)["live_in_ticker"] is False
def test_false_chosen_after_the_migration_stays_false(files):
chosen = {"display": {"brightness": 90,
"vegas_scroll": {"enabled": True, "live_in_ticker": False,
MARKER: True}}}
_manager, saved = _load(files, chosen)
assert _vegas(saved)["live_in_ticker"] is False
def test_a_marked_config_is_not_written_again(files):
marked = {"display": {"brightness": 90,
"vegas_scroll": {"enabled": True, "live_in_ticker": True,
MARKER: True}}}
files["config"].write_text(json.dumps(marked))
before = files["config"].stat().st_mtime_ns
manager = ConfigManager(config_path=str(files["config"]),
secrets_path=str(files["secrets"]))
manager.template_path = str(files["template"])
manager.load_config()
assert files["config"].stat().st_mtime_ns == before
assert not Path(f"{files['config']}.backup").exists()
def test_a_config_without_vegas_settings_gets_the_new_default(files):
_manager, saved = _load(files, {"display": {"brightness": 90}})
assert _vegas(saved)["live_in_ticker"] is True
assert _vegas(saved)[MARKER] is True
def test_true_already_is_only_marked(files):
on = {"display": {"brightness": 90,
"vegas_scroll": {"enabled": True, "live_in_ticker": True}}}
_manager, saved = _load(files, on)
assert _vegas(saved)["live_in_ticker"] is True and _vegas(saved)[MARKER] is True
def test_a_second_load_keeps_a_false_set_in_between(files):
old = {"display": {"brightness": 90,
"vegas_scroll": {"enabled": True, "live_in_ticker": False}}}
_manager, saved = _load(files, old)
_vegas(saved)["live_in_ticker"] = False # the checkbox, unticked
_manager, saved = _load(files, saved)
assert _vegas(saved)["live_in_ticker"] is False
def test_the_shipped_template_has_the_new_default_and_never_the_marker():
template = json.loads((REPO / "config" / "config.template.json").read_text(encoding="utf-8"))
vegas = template["display"]["vegas_scroll"]
assert vegas["live_in_ticker"] is True
assert MARKER not in vegas
+4
View File
@@ -67,10 +67,13 @@ class TestConfigWhileStopped:
class TestLivePriorityThrottle:
"""The full-screen takeover's live scan: live_in_ticker off, no longer the default."""
def test_scan_runs_at_most_once_per_interval(self, monkeypatch):
now = [1000.0]
monkeypatch.setattr(coordinator_module.time, 'monotonic', lambda: now[0])
c = _coordinator()
c.vegas_config.live_in_ticker = False
c._live_priority_check = MagicMock(return_value=None)
for _ in range(10):
@@ -84,6 +87,7 @@ class TestLivePriorityThrottle:
def test_live_content_still_pauses_vegas(self, monkeypatch):
monkeypatch.setattr(coordinator_module.time, 'monotonic', lambda: 1000.0)
c = _coordinator()
c.vegas_config.live_in_ticker = False
c._live_priority_check = MagicMock(return_value='nfl_live')
c.pause = MagicMock()
assert c.run_frame() is False
+7 -2
View File
@@ -58,11 +58,16 @@ def test_an_iteration_does_not_poll_every_plugin_for_its_mode(caplog):
def _live_coordinator(live):
"""A coordinator running the real run_frame(), with a switchable live check."""
"""A coordinator running the real run_frame(), with a switchable live check.
live_in_ticker off: these pin the full-screen takeover, which is no longer
the default.
"""
coord = _coordinator({})
del coord.run_frame # the real one: it is what refuses frames while paused
coord.vegas_config = VegasModeConfig.from_config({'display': {'vegas_scroll': {
'enabled': True, 'max_cycle_duration': 0, 'continuous_scroll': True}}})
'enabled': True, 'max_cycle_duration': 0, 'continuous_scroll': True,
'live_in_ticker': False}}})
coord.render_pipeline.has_deferred.return_value = False
coord.render_pipeline.needs_extension.return_value = False
coord.render_pipeline.render_frame.return_value = True
+6 -6
View File
@@ -5,10 +5,10 @@ live content did not appear in it at all, because the display controller
refused to run the ticker while anything was live. With a dozen plugins
enabled that left a live score either absent or minutes stale.
Two things change, both off by default. `live_in_ticker` keeps the marquee
running instead of yielding to a full-screen takeover, and the rotation is
expanded by Smooth Weighted Round-Robin so a weighted plugin gets several
slots per cycle, spaced through it rather than clumped.
Two things change. `live_in_ticker` keeps the marquee running instead of
yielding to a full-screen takeover (on by default since 3.8.0), and the
rotation is expanded by Smooth Weighted Round-Robin so a weighted plugin gets
several slots per cycle, spaced through it rather than clumped.
Weights are per plugin, not per game: a scoreboard showing four live games
still occupies one slot at a time and rotates its own games within it.
@@ -288,9 +288,9 @@ class TestTheSchedule:
class TestConfigParsing:
def test_defaults_preserve_todays_behaviour(self):
def test_live_games_stay_in_the_ticker_by_default(self):
cfg = VegasModeConfig.from_config({})
assert cfg.live_in_ticker is False
assert cfg.live_in_ticker is True
assert cfg.live_weight == 3 and cfg.favorite_live_weight == 5
@pytest.mark.parametrize("given,expected", [(0, 1), (-4, 1), (99, 10), (4, 4)])
+3 -1
View File
@@ -802,7 +802,8 @@ def save_main_config():
'vegas_intra_plugin_gap', 'vegas_render_width_pct',
'vegas_min_content_separation', 'vegas_min_cut_gap',
'vegas_continuous_scroll', 'vegas_extend_threshold_screens',
'vegas_smooth_scroll', 'vegas_overflow_mode', 'vegas_live_refresh']
'vegas_smooth_scroll', 'vegas_overflow_mode', 'vegas_live_refresh',
'vegas_live_in_ticker']
if any(k in data for k in vegas_fields):
if 'display' not in current_config:
@@ -823,6 +824,7 @@ def save_main_config():
_set_checkbox(vegas_config, 'continuous_scroll', 'vegas_continuous_scroll')
_set_checkbox(vegas_config, 'smooth_scroll', 'vegas_smooth_scroll')
_set_checkbox(vegas_config, 'live_refresh', 'vegas_live_refresh')
_set_checkbox(vegas_config, 'live_in_ticker', 'vegas_live_in_ticker')
# max_plugin_width_ratio is the one fractional setting, so it is
# handled outside the integer loop below.
@@ -579,6 +579,18 @@
</label>
</div>
<div class="form-group mb-4" id="setting-display-vegas_live_in_ticker" data-setting-key="display.vegas_scroll.live_in_ticker">
<label class="flex items-center">
<input type="checkbox"
id="vegas_live_in_ticker"
name="vegas_live_in_ticker"
{% if main_config.display.get('vegas_scroll', {}).get('live_in_ticker', True) %}checked{% endif %}
class="form-checkbox">
<span class="ml-2 text-sm text-gray-700">Keep live games in the ticker{{ ui.help_tip('While a game is live, keep the ticker running and give the live scoreboard extra turns in it (Live Weight). Default: on.
With this off a live game takes over the whole display with the full-screen scoreboard until it ends, and the ticker stops.', 'Live Games') }}</span>
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div class="form-group" id="setting-display-vegas_extend_threshold_screens" data-setting-key="display.vegas_scroll.extend_threshold_screens">
<label for="vegas_extend_threshold_screens" class="block text-sm font-medium text-gray-700">Extend When (screens left){{ ui.help_tip('How much unscrolled content triggers loading the next group, measured in screen widths (1.0–10.0).\nDefault: 2. Higher loads earlier and leaves more slack, at the cost of holding more content in memory. Only applies when Continuous Scroll is on.', 'Extend Threshold') }}</label>