Add overflow handling: keep ordered content whole instead of rotating a window

The width budget split any oversized plugin by advancing a window each cycle.
That is right for interchangeable items — news headlines, odds, stock prices —
but wrong for ordered content: a league table showed ranks 1-6, then resumed at
7 two rotations later, which reads as out of order and out of context. Nobody
needs rank 23 in a ticker; they need the top of the table, every time.

overflow_mode chooses between them:

  rotate   — advance a window each cycle so everything is seen eventually
             (unchanged default)
  truncate — always show the start and drop the rest, keeping ordered content
             coherent. Records no window state, so every pass starts at the top.

Per-plugin vegas_overflow overrides the global setting, since one install has
both kinds of plugin. Also adds per-plugin vegas_max_width_screens, so content
that must stay whole can be given more room — or uncapped with 0 — without
lifting the cap on every ticker.

Applied on the test rig: f1-scoreboard and ledmatrix-leaderboard set to
truncate, and baseball given 4.5 screens because it was showing 8 of 9 games
when the whole slate needed only a little more room. Verified: F1 now reports
"the first 10 of 116 ... the rest are not shown", baseball has dropped out of
the budget log entirely, and stocks, odds-ticker and stock-news still rotate.

Also corrects the crop log, which claimed "window advances next cycle"
unconditionally and so misreported truncated crops. A test now pins the
behaviour behind the message: truncate must leave no offset recorded.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
This commit is contained in:
ChuckBuilds
2026-07-29 15:40:37 -04:00
co-authored by Claude
parent 7b6b7cd153
commit a413849891
6 changed files with 283 additions and 30 deletions
+1
View File
@@ -145,6 +145,7 @@
"lead_in_width": 0, "lead_in_width": 0,
"plugins_per_cycle": 6, "plugins_per_cycle": 6,
"max_plugin_width_ratio": 3.0, "max_plugin_width_ratio": 3.0,
"overflow_mode": "rotate",
"dynamic_duration_enabled": true, "dynamic_duration_enabled": true,
"min_cycle_duration": 60, "min_cycle_duration": 60,
"max_cycle_duration": 240 "max_cycle_duration": 240
+21
View File
@@ -91,6 +91,18 @@ class VegasModeConfig:
# letter floating between two unrelated plugins. # letter floating between two unrelated plugins.
min_cut_gap: int = 6 min_cut_gap: int = 6
# What to do when a plugin's content exceeds its width budget.
#
# "rotate" — advance a window each cycle so everything is seen eventually.
# Right for interchangeable items: news headlines, odds, stocks.
# "truncate" — always show the start. Right for ordered content, where a
# window into the middle is meaningless: a league table that
# shows ranks 1-6 then resumes at 7 two rotations later reads
# as out of order and out of context.
#
# Override per plugin with vegas_overflow.
overflow_mode: str = "rotate"
# Cap on one plugin's share of a cycle, as a multiple of display width. # Cap on one plugin's share of a cycle, as a multiple of display width.
# A single ticker returning 7,000px would otherwise hold the panel for over # A single ticker returning 7,000px would otherwise hold the panel for over
# two minutes. Overflow is deferred to later cycles rather than discarded. # two minutes. Overflow is deferred to later cycles rather than discarded.
@@ -148,6 +160,7 @@ class VegasModeConfig:
plugins_per_cycle=int(vegas_config.get('plugins_per_cycle', 6)), plugins_per_cycle=int(vegas_config.get('plugins_per_cycle', 6)),
max_plugin_width_ratio=float( max_plugin_width_ratio=float(
vegas_config.get('max_plugin_width_ratio', 3.0)), vegas_config.get('max_plugin_width_ratio', 3.0)),
overflow_mode=str(vegas_config.get('overflow_mode', 'rotate')),
plugin_order=list(vegas_config.get('plugin_order', [])), plugin_order=list(vegas_config.get('plugin_order', [])),
excluded_plugins=set(vegas_config.get('excluded_plugins', [])), excluded_plugins=set(vegas_config.get('excluded_plugins', [])),
target_fps=int(vegas_config.get('target_fps', 125)), target_fps=int(vegas_config.get('target_fps', 125)),
@@ -179,6 +192,7 @@ class VegasModeConfig:
'lead_in_width': self.lead_in_width, 'lead_in_width': self.lead_in_width,
'plugins_per_cycle': self.plugins_per_cycle, 'plugins_per_cycle': self.plugins_per_cycle,
'max_plugin_width_ratio': self.max_plugin_width_ratio, 'max_plugin_width_ratio': self.max_plugin_width_ratio,
'overflow_mode': self.overflow_mode,
'plugin_order': self.plugin_order, 'plugin_order': self.plugin_order,
'excluded_plugins': list(self.excluded_plugins), 'excluded_plugins': list(self.excluded_plugins),
'target_fps': self.target_fps, 'target_fps': self.target_fps,
@@ -322,6 +336,11 @@ class VegasModeConfig:
errors.append( errors.append(
f"plugins_per_cycle must be <= 50, got {self.plugins_per_cycle}") f"plugins_per_cycle must be <= 50, got {self.plugins_per_cycle}")
if self.overflow_mode not in ('rotate', 'truncate'):
errors.append(
"overflow_mode must be 'rotate' or 'truncate', "
f"got {self.overflow_mode!r}")
if self.max_plugin_width_ratio < 0: if self.max_plugin_width_ratio < 0:
errors.append( errors.append(
"max_plugin_width_ratio must be >= 0 " "max_plugin_width_ratio must be >= 0 "
@@ -375,6 +394,8 @@ class VegasModeConfig:
if 'max_plugin_width_ratio' in vegas_config: if 'max_plugin_width_ratio' in vegas_config:
self.max_plugin_width_ratio = float( self.max_plugin_width_ratio = float(
vegas_config['max_plugin_width_ratio']) vegas_config['max_plugin_width_ratio'])
if 'overflow_mode' in vegas_config:
self.overflow_mode = str(vegas_config['overflow_mode'])
if 'plugin_order' in vegas_config: if 'plugin_order' in vegas_config:
self.plugin_order = list(vegas_config['plugin_order']) self.plugin_order = list(vegas_config['plugin_order'])
if 'excluded_plugins' in vegas_config: if 'excluded_plugins' in vegas_config:
+114 -29
View File
@@ -120,7 +120,7 @@ class PluginAdapter:
"[%s] Native content SUCCESS: %d images, %dpx total", "[%s] Native content SUCCESS: %d images, %dpx total",
plugin_id, len(content), total_width plugin_id, len(content), total_width
) )
return self._finalize(content, plugin_id, 'native') return self._finalize(content, plugin_id, 'native', plugin)
logger.info("[%s] Native content returned None", plugin_id) logger.info("[%s] Native content returned None", plugin_id)
# Try to get scroll_helper's cached image (for scrolling plugins like stocks/odds) # Try to get scroll_helper's cached image (for scrolling plugins like stocks/odds)
@@ -133,7 +133,7 @@ class PluginAdapter:
"[%s] ScrollHelper content SUCCESS: %d images, %dpx total", "[%s] ScrollHelper content SUCCESS: %d images, %dpx total",
plugin_id, len(content), total_width plugin_id, len(content), total_width
) )
return self._finalize(content, plugin_id, 'scroll_helper') return self._finalize(content, plugin_id, 'scroll_helper', plugin)
if has_scroll_helper: if has_scroll_helper:
logger.info("[%s] ScrollHelper content returned None", plugin_id) logger.info("[%s] ScrollHelper content returned None", plugin_id)
@@ -154,7 +154,7 @@ class PluginAdapter:
"[%s] Fallback capture SUCCESS: %d images, %dpx total", "[%s] Fallback capture SUCCESS: %d images, %dpx total",
plugin_id, len(content), total_width plugin_id, len(content), total_width
) )
return self._finalize(content, plugin_id, 'fallback') return self._finalize(content, plugin_id, 'fallback', plugin)
logger.warning( logger.warning(
"[%s] NO CONTENT from any method (native=%s, scroll_helper=%s, fallback=tried)", "[%s] NO CONTENT from any method (native=%s, scroll_helper=%s, fallback=tried)",
@@ -163,7 +163,8 @@ class PluginAdapter:
return None return None
def _finalize( def _finalize(
self, images: List[Image.Image], plugin_id: str, source: str self, images: List[Image.Image], plugin_id: str, source: str,
plugin: Optional['BasePlugin'] = None
) -> Optional[List[Image.Image]]: ) -> Optional[List[Image.Image]]:
""" """
Trim dead space off a segment, then cache it. Trim dead space off a segment, then cache it.
@@ -190,7 +191,7 @@ class PluginAdapter:
# turning off margin cropping should not let one plugin hold the # turning off margin cropping should not let one plugin hold the
# panel for minutes. Skipping it here previously let a 14,848px # panel for minutes. Skipping it here previously let a 14,848px
# segment through untouched. # segment through untouched.
kept = self._apply_width_budget(list(images), plugin_id) kept = self._apply_width_budget(list(images), plugin_id, plugin)
self._cache_content(plugin_id, kept) self._cache_content(plugin_id, kept)
return kept return kept
@@ -235,7 +236,7 @@ class PluginAdapter:
len(kept), dropped_blank len(kept), dropped_blank
) )
kept = self._apply_width_budget(kept, plugin_id) kept = self._apply_width_budget(kept, plugin_id, plugin)
self._cache_content(plugin_id, kept) self._cache_content(plugin_id, kept)
return kept return kept
@@ -332,15 +333,74 @@ class PluginAdapter:
threshold=self.config.trim_threshold, threshold=self.config.trim_threshold,
) )
def _width_budget(self) -> int: def _plugin_setting(self, plugin: 'BasePlugin', key: str):
"""Maximum columns one plugin may occupy in a cycle. 0 means unlimited.""" """Read a per-plugin config override, or None if absent."""
plugin_cfg = getattr(plugin, 'config', None)
if not isinstance(plugin_cfg, dict):
return None
value = plugin_cfg.get(key)
return None if value in (None, '') else value
def resolve_overflow_mode(self, plugin: 'BasePlugin', plugin_id: str) -> str:
"""
How to handle content that exceeds this plugin's width budget.
'rotate' advances a window each cycle so everything is seen eventually,
which suits interchangeable items. 'truncate' always shows the start,
which suits ordered content — a league table that shows ranks 1-6 and
then resumes at 7 two rotations later reads as out of order, and nobody
needs rank 23 in a ticker anyway.
Per-plugin ``vegas_overflow`` wins over the global ``overflow_mode``.
"""
raw = self._plugin_setting(plugin, 'vegas_overflow')
if raw is not None:
candidate = str(raw).strip().lower()
if candidate in ('rotate', 'truncate'):
return candidate
logger.warning(
"[%s] Invalid vegas_overflow %r, expected 'rotate' or 'truncate'",
plugin_id, raw
)
return self.config.overflow_mode
def _width_budget(self, plugin: Optional['BasePlugin'] = None,
plugin_id: str = '') -> int:
"""
Maximum columns one plugin may occupy in a cycle. 0 means unlimited.
A per-plugin ``vegas_max_width_screens`` overrides the global ratio, so
content that has to stay whole can be given room (or uncapped with 0)
without lifting the cap on every ticker.
"""
ratio = self.config.max_plugin_width_ratio ratio = self.config.max_plugin_width_ratio
if plugin is not None:
raw = self._plugin_setting(plugin, 'vegas_max_width_screens')
if raw is not None:
try:
candidate = float(raw)
except (TypeError, ValueError):
logger.warning(
"[%s] Invalid vegas_max_width_screens %r, ignoring",
plugin_id, raw
)
else:
if candidate >= 0:
ratio = candidate
else:
logger.warning(
"[%s] vegas_max_width_screens must be >= 0, got %s",
plugin_id, candidate
)
if ratio <= 0: if ratio <= 0:
return 0 return 0
return int(self.display_width * ratio) return int(self.display_width * ratio)
def _apply_width_budget( def _apply_width_budget(
self, images: List[Image.Image], plugin_id: str self, images: List[Image.Image], plugin_id: str,
plugin: Optional['BasePlugin'] = None
) -> List[Image.Image]: ) -> List[Image.Image]:
""" """
Hold one plugin to its share of a cycle. Hold one plugin to its share of a cycle.
@@ -359,7 +419,9 @@ class PluginAdapter:
Images that fit the budget, starting from the plugin's current Images that fit the budget, starting from the plugin's current
rotation offset. rotation offset.
""" """
budget = self._width_budget() budget = self._width_budget(plugin, plugin_id)
mode = (self.resolve_overflow_mode(plugin, plugin_id)
if plugin is not None else self.config.overflow_mode)
# Count the gaps the compositor will actually insert, not just the # Count the gaps the compositor will actually insert, not just the
# pixels of the rows — otherwise a plugin with many rows quietly # pixels of the rows — otherwise a plugin with many rows quietly
@@ -377,9 +439,15 @@ class PluginAdapter:
return images return images
if len(images) == 1: if len(images) == 1:
return [self._crop_to_budget(images[0], budget, plugin_id)] return [self._crop_to_budget(images[0], budget, plugin_id, mode)]
start = self._item_offsets.get(plugin_id, 0) % len(images) if mode == 'truncate':
# Ordered content: always show from the top. Deliberately does not
# advance the offset, so the same opening items appear every time
# rather than the viewer being shown the middle of a ranked list.
start = 0
else:
start = self._item_offsets.get(plugin_id, 0) % len(images)
selected: List[Image.Image] = [] selected: List[Image.Image] = []
used = 0 used = 0
consumed = 0 consumed = 0
@@ -397,17 +465,24 @@ class PluginAdapter:
used += cost used += cost
consumed += 1 consumed += 1
self._item_offsets[plugin_id] = (start + consumed) % len(images) if mode == 'truncate':
logger.info(
logger.info( "[%s] Width budget %dpx: showing the first %d of %d row(s) "
"[%s] Width budget %dpx: showing %d of %d row(s) (%dpx incl. gaps) " "(%dpx incl. gaps); the rest are not shown (overflow=truncate)",
"from offset %d; remainder deferred to a later cycle", plugin_id, budget, len(selected), len(images), used
plugin_id, budget, len(selected), len(images), used, start )
) else:
self._item_offsets[plugin_id] = (start + consumed) % len(images)
logger.info(
"[%s] Width budget %dpx: showing %d of %d row(s) (%dpx incl. gaps) "
"from offset %d; remainder deferred to a later cycle",
plugin_id, budget, len(selected), len(images), used, start
)
return selected return selected
def _crop_to_budget( def _crop_to_budget(
self, img: Image.Image, budget: int, plugin_id: str self, img: Image.Image, budget: int, plugin_id: str,
mode: str = 'rotate'
) -> Image.Image: ) -> Image.Image:
""" """
Narrow a single oversized image to the budget, advancing a window Narrow a single oversized image to the budget, advancing a window
@@ -416,9 +491,14 @@ class PluginAdapter:
The cut is snapped to the nearest blank column so it does not slice The cut is snapped to the nearest blank column so it does not slice
through a glyph or logo and leave half a character at the panel edge. through a glyph or logo and leave half a character at the panel edge.
""" """
offset = self._item_offsets.get(plugin_id, 0) if mode == 'truncate':
if offset >= img.width: # Always the start of the strip, so a ranked table is never entered
# from the middle.
offset = 0 offset = 0
else:
offset = self._item_offsets.get(plugin_id, 0)
if offset >= img.width:
offset = 0
# Cut only where the plugin left a real gap between items. Snapping to # Cut only where the plugin left a real gap between items. Snapping to
# any blank column used to pick the single-column gaps between # any blank column used to pick the single-column gaps between
@@ -435,11 +515,13 @@ class PluginAdapter:
# (words, ticker entries); it would be wrong to let a solid image # (words, ticker entries); it would be wrong to let a solid image
# escape the cap in its name. # escape the cap in its name.
end = min(offset + budget, img.width) end = min(offset + budget, img.width)
self._item_offsets[plugin_id] = 0 if end >= img.width else end if mode != 'truncate':
self._item_offsets[plugin_id] = 0 if end >= img.width else end
logger.info( logger.info(
"[%s] Width budget %dpx: cropped continuous %dpx image to " "[%s] Width budget %dpx: cropped continuous %dpx image to "
"[%d:%d] (no item gaps of %dpx+ to align to)", "[%d:%d] (no item gaps of %dpx+ to align to)%s",
plugin_id, budget, img.width, offset, end, min_run plugin_id, budget, img.width, offset, end, min_run,
"" if mode != 'truncate' else "; showing the start only"
) )
return img.crop((offset, 0, end, img.height)) return img.crop((offset, 0, end, img.height))
@@ -456,13 +538,16 @@ class PluginAdapter:
# because the alternative is cutting through an item. # because the alternative is cutting through an item.
end = max(within) if within else min(later) end = max(within) if within else min(later)
# Next cycle resumes where this one stopped; wrap when the strip ends. if mode != 'truncate':
self._item_offsets[plugin_id] = 0 if end >= img.width else end # Next cycle resumes where this one stopped; wrap when the strip ends.
self._item_offsets[plugin_id] = 0 if end >= img.width else end
logger.info( logger.info(
"[%s] Width budget %dpx: cropped single %dpx image to [%d:%d] " "[%s] Width budget %dpx: cropped single %dpx image to [%d:%d] "
"(%dpx) at item boundaries, window advances next cycle", "(%dpx) at item boundaries, %s",
plugin_id, budget, img.width, start, end, end - start plugin_id, budget, img.width, start, end, end - start,
"showing the start only (overflow=truncate)"
if mode == 'truncate' else "window advances next cycle"
) )
return img.crop((start, 0, end, img.height)) return img.crop((start, 0, end, img.height))
+128
View File
@@ -1509,3 +1509,131 @@ class TestDeferredDraining:
p.extend_scroll_content() p.extend_scroll_content()
assert p.drain_deferred() is True assert p.drain_deferred() is True
assert p.drain_deferred() is True assert p.drain_deferred() is True
class TestOverflowMode:
"""
Rotating a window through content only makes sense when the items are
interchangeable. For ordered content it shows the middle of a ranked list —
ranks 1-6, then 7 onwards two rotations later, which reads as out of order.
"""
class Cfg:
def __init__(self, cfg=None):
self.config = cfg or {}
def get_vegas_content(self):
return None
def test_default_is_rotate(self):
adapter = adapter_with()
assert adapter.resolve_overflow_mode(self.Cfg(), 'p') == 'rotate'
def test_global_mode_applies(self):
adapter = adapter_with(overflow_mode='truncate')
assert adapter.resolve_overflow_mode(self.Cfg(), 'p') == 'truncate'
def test_per_plugin_override_wins(self):
adapter = adapter_with(overflow_mode='rotate')
plugin = self.Cfg({'vegas_overflow': 'truncate'})
assert adapter.resolve_overflow_mode(plugin, 'p') == 'truncate'
@pytest.mark.parametrize('bad', ['sideways', '', None, 5])
def test_invalid_override_falls_back(self, bad):
adapter = adapter_with(overflow_mode='rotate')
plugin = self.Cfg({'vegas_overflow': bad})
assert adapter.resolve_overflow_mode(plugin, 'p') == 'rotate'
def test_truncate_always_shows_the_same_opening_rows(self):
# The reported problem: a ranked list should not resume from the middle.
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
intra_plugin_gap=0, min_content_separation=0,
overflow_mode='truncate')
rows = [canvas([(0, 200)], width=200) for _ in range(8)]
plugin = NativePlugin(rows)
first = adapter.get_content(plugin, 'ranks')
adapter.invalidate_cache('ranks')
second = adapter.get_content(plugin, 'ranks')
assert len(first) == len(second)
assert 'ranks' not in adapter._item_offsets, "must not advance a window"
def test_rotate_still_advances(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
intra_plugin_gap=0, min_content_separation=0,
overflow_mode='rotate')
rows = [canvas([(0, 200)], width=200) for _ in range(8)]
adapter.get_content(NativePlugin(rows), 'ticker')
assert adapter._item_offsets.get('ticker', 0) > 0
def test_truncate_on_a_single_wide_image_starts_at_zero(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
overflow_mode='truncate')
strip = canvas([(0, 5000)], width=5000)
plugin = NativePlugin([strip])
first = adapter.get_content(plugin, 'table')[0]
adapter.invalidate_cache('table')
second = adapter.get_content(plugin, 'table')[0]
assert first.tobytes() == second.tobytes(), "window must not advance"
class TestPerPluginWidthBudget:
class Cfg:
def __init__(self, cfg=None):
self.config = cfg or {}
def get_vegas_content(self):
return None
def test_global_ratio_by_default(self):
adapter = adapter_with(max_plugin_width_ratio=3.0)
assert adapter._width_budget(self.Cfg(), 'p') == DISPLAY_W * 3
def test_per_plugin_override_widens(self):
adapter = adapter_with(max_plugin_width_ratio=3.0)
plugin = self.Cfg({'vegas_max_width_screens': 8})
assert adapter._width_budget(plugin, 'p') == DISPLAY_W * 8
def test_per_plugin_zero_means_uncapped(self):
adapter = adapter_with(max_plugin_width_ratio=3.0)
plugin = self.Cfg({'vegas_max_width_screens': 0})
assert adapter._width_budget(plugin, 'p') == 0
def test_uncapped_plugin_keeps_all_its_content(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
class Wide(NativePlugin):
def __init__(self, images):
super().__init__(images)
self.config = {'vegas_max_width_screens': 0}
strip = canvas([(0, 6000)], width=6000)
assert adapter.get_content(Wide([strip]), 'whole')[0].width == 6000
@pytest.mark.parametrize('bad', ['wide', -1, None, ''])
def test_invalid_override_falls_back_to_global(self, bad):
adapter = adapter_with(max_plugin_width_ratio=2.0)
plugin = self.Cfg({'vegas_max_width_screens': bad})
assert adapter._width_budget(plugin, 'p') == DISPLAY_W * 2
def test_no_plugin_uses_the_global(self):
adapter = adapter_with(max_plugin_width_ratio=2.0)
assert adapter._width_budget() == DISPLAY_W * 2
def test_truncate_single_image_never_records_an_offset(self):
# The behavioural guarantee behind the log message: no window state is
# kept, so every pass starts at the top.
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
overflow_mode='truncate')
strip = canvas([(0, 5000)], width=5000)
for _ in range(4):
adapter.invalidate_cache('table')
adapter.get_content(NativePlugin([strip]), 'table')
assert 'table' not in adapter._item_offsets
def test_rotate_single_image_does_record_an_offset(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
overflow_mode='rotate')
strip = canvas([(0, 5000)], width=5000)
adapter.get_content(NativePlugin([strip]), 'ticker')
assert adapter._item_offsets.get('ticker', 0) > 0
+11 -1
View File
@@ -926,7 +926,7 @@ def save_main_config():
'vegas_intra_plugin_gap', 'vegas_render_width_pct', 'vegas_intra_plugin_gap', 'vegas_render_width_pct',
'vegas_min_content_separation', 'vegas_min_cut_gap', 'vegas_min_content_separation', 'vegas_min_cut_gap',
'vegas_continuous_scroll', 'vegas_extend_threshold_screens', 'vegas_continuous_scroll', 'vegas_extend_threshold_screens',
'vegas_smooth_scroll'] 'vegas_smooth_scroll', 'vegas_overflow_mode']
if any(k in data for k in vegas_fields): if any(k in data for k in vegas_fields):
if 'display' not in current_config: if 'display' not in current_config:
@@ -951,6 +951,16 @@ def save_main_config():
# max_plugin_width_ratio is the one fractional setting, so it is # max_plugin_width_ratio is the one fractional setting, so it is
# handled outside the integer loop below. # handled outside the integer loop below.
if data.get('vegas_overflow_mode') not in ('', None):
mode = str(data['vegas_overflow_mode']).strip().lower()
if mode not in ('rotate', 'truncate'):
return jsonify({
'status': 'error',
'message': "Invalid value for vegas_overflow_mode: "
"must be 'rotate' or 'truncate'"
}), 400
vegas_config['overflow_mode'] = mode
if data.get('vegas_extend_threshold_screens') not in ('', None): if data.get('vegas_extend_threshold_screens') not in ('', None):
try: try:
screens = float(data['vegas_extend_threshold_screens']) screens = float(data['vegas_extend_threshold_screens'])
@@ -547,6 +547,14 @@
class="form-control"> class="form-control">
</div> </div>
<div class="form-group" id="setting-display-vegas_overflow_mode" data-setting-key="display.vegas_scroll.overflow_mode">
<label for="vegas_overflow_mode" class="block text-sm font-medium text-gray-700">When A Plugin Is Too Wide{{ ui.help_tip('What to do when a plugin has more content than its width allowance.\nRotate through it: show a different slice each time round, so everything is seen eventually. Right for interchangeable items like news headlines, odds or stock prices.\nShow the start only: always display from the beginning and drop the rest. Right for ordered content — a league table that shows ranks 1-6 and then resumes at 7 two rotations later reads as out of order.\nDefault: rotate. Override for one plugin with vegas_overflow in its own settings.', 'Overflow Handling') }}</label>
<select id="vegas_overflow_mode" name="vegas_overflow_mode" class="form-control">
<option value="rotate" {% if main_config.display.get('vegas_scroll', {}).get('overflow_mode', 'rotate') == 'rotate' %}selected{% endif %}>Rotate through it (default)</option>
<option value="truncate" {% if main_config.display.get('vegas_scroll', {}).get('overflow_mode', 'rotate') == 'truncate' %}selected{% endif %}>Show the start only</option>
</select>
</div>
<div class="form-group" id="setting-display-vegas_max_plugin_width_ratio" data-setting-key="display.vegas_scroll.max_plugin_width_ratio"> <div class="form-group" id="setting-display-vegas_max_plugin_width_ratio" data-setting-key="display.vegas_scroll.max_plugin_width_ratio">
<label for="vegas_max_plugin_width_ratio" class="block text-sm font-medium text-gray-700">Max Plugin Width (screens){{ ui.help_tip('Caps how much of one cycle a single plugin may occupy, measured in screen widths (020).\nDefault: 3. A long ticker such as a news feed or leaderboard is trimmed to this and the remainder shown on later cycles, so one plugin cannot hold the display for minutes. Set 0 for no limit.', 'Max Plugin Width') }}</label> <label for="vegas_max_plugin_width_ratio" class="block text-sm font-medium text-gray-700">Max Plugin Width (screens){{ ui.help_tip('Caps how much of one cycle a single plugin may occupy, measured in screen widths (020).\nDefault: 3. A long ticker such as a news feed or leaderboard is trimmed to this and the remainder shown on later cycles, so one plugin cannot hold the display for minutes. Set 0 for no limit.', 'Max Plugin Width') }}</label>
<input type="number" <input type="number"