Skip to content

news: stop cutting headlines short, add headline paging - #226

Merged
ChuckBuilds merged 2 commits into
mainfrom
claude/news-ticker-headline-display-q1pyrc
Jul 28, 2026
Merged

news: stop cutting headlines short, add headline paging#226
ChuckBuilds merged 2 commits into
mainfrom
claude/news-ticker-headline-display-q1pyrc

Conversation

@ChuckBuilds

Copy link
Copy Markdown
Owner

Pull Request

Summary

The news ticker was being pulled off screen mid-headline no matter how much content was left to scroll. This fixes the root cause (the plugin never actually opted into dynamic duration, so the display controller cut it at a flat wall-clock time) and adds headline paging, which sizes each display turn to what can genuinely finish scrolling and resumes the next turn where the last one stopped.

Type of change

  • Bug fix in an existing plugin
  • New plugin (also fill out the SUBMISSION.md checklist below)
  • New feature for an existing plugin
  • Documentation only
  • Repo-wide change (registry script, hook, top-level docs)

Plugin(s) affected

news

Related issues

N/A

Test plan

  • Loaded the plugin in LEDMatrix on real hardware
  • Loaded the plugin in LEDMatrix emulator mode (EMULATOR=true python3 run.py)
  • Rendered the plugin in the dev preview server (scripts/dev_server.py)
  • Verified the web UI configuration form against the schema
  • N/A — repo-wide / docs-only change

Testing done, against a core checkout:

  • Plugin safety harnessscripts/check_plugin.py --plugin news: PASS at all 8 panel sizes (64x32, 128x32, 64x64, 96x48, 128x64, 256x32, 128x96, 256x128).
  • New test module plugins/news/test_headline_paging.py (13 tests) covering page sizing against the duration budget, page ordering (every headline reached, in order, no repeats), the oversized-single-headline case, paging disabled, max_headlines_per_page, the parked-page rollover, refresh not rewinding the page window, and each dynamic-duration hook.
  • End-to-end display-loop simulation emulating the controller's reset_cycle_state() → frames → is_cycle_complete() sequence bounded by get_cycle_duration(): 24 headlines walked across 5 turns, every page finishing inside its wall.
  • python scripts/check_module_collisions.py — clean.
  • Manifest validated against the core's schema/manifest_schema.json.

Not verified on real hardware — the timing behaviour under a loaded Pi (the case the overrun allowance exists for) is worth a look on device.

Required for plugin changes

  • Bumped version in plugins/<id>/manifest.json (1.0.10 → 1.1.0)
  • class_name in manifest.json matches the actual class in manager.py exactly
  • entry_point matches the real file
  • Updated the plugin's README.md (new config keys + a Headline Paging section + troubleshooting entries)
  • config_schema.json is the source of truth for the web UI form — the new global.headline_paging block has defaults, descriptions and constraints, with the fine-tuning keys marked x-advanced
  • Pre-commit hook ran successfully (auto-synced plugins.json)

Checklist

  • My commits follow the message convention in CONTRIBUTING.md
  • I read CONTRIBUTING.md and CODE_OF_CONDUCT.md
  • I've not committed any secrets

Notes for reviewer

The root cause is worth reading carefully, because it's a trap other plugins could fall into.

BasePlugin.supports_dynamic_duration() reads a top-level dynamic_duration config key. This plugin has always kept that block under global.dynamic_duration, so the base implementation found nothing and returned False. In display_controller.py that means dynamic_enabled = False, which sends the mode down the else branch: max_duration = base_duration, _should_exit_dynamic() short-circuits to False, and is_cycle_complete() is never consulted. The ticker got exactly one fixed number of seconds and was then cut wherever it happened to be — on the very first turn that number was ScrollHelper's calculated_duration = 60 placeholder, handed out before any strip existed.

So all the machinery for "don't cut headlines off" was already written; it just was never reachable. This PR implements the rest of the hook family (supports_dynamic_duration, get_cycle_duration, get_dynamic_duration_cap, reset_cycle_state) so the controller waits for the scroll to actually finish.

Two decisions I'd like a second opinion on:

  1. Overrun slack (duration_overrun_allowance, default 0.25). The controller treats get_cycle_duration() as a hard wall. Under load a Pi renders below the nominal frame rate, and ScrollHelper.update_scroll_position() caps catch-up at max_steps — it drops the missed pixels rather than making them up, so the strip finishes progressively behind schedule and the wall lands mid-headline. The slack absorbs that. It's also folded into get_dynamic_duration_cap(), because the controller takes min(cycle duration, cap) and a cap set to the bare page duration would clip exactly the slack that's meant to help. The allowance only costs real time when the scroll genuinely ran late — the controller still hands over the instant is_cycle_complete() flips.

  2. CORE_DEFAULT_DURATION_CAP = 180.0 mirrors a core constant. Page sizing has to know the ceiling that will actually be enforced, which is min(plugin cap, display.dynamic_duration.max_duration_seconds), and the core falls back to its own 180s default when that key is unset. The plugin reads the real value from config_manager when it can and only falls back to the mirrored constant otherwise, but it is a deliberate coupling to core behaviour and worth flagging.

Behaviour notes:

  • Paging defaults to on. With it off, the previous single-strip behaviour and the rotation_enabled/rotation_threshold path are preserved unchanged — those two keys are now documented as only applying when paging is disabled.
  • The old rotation advanced one headline every rotation_threshold (default 3) completed cycles. With ~18 headlines that needed ~51 cycles to bring the last one to the front, and since cycles were being cut early the tail was effectively unreachable. Paging advances a whole page per cycle instead.
  • update() deliberately keeps _page_start across data refreshes rather than rewinding. Rewinding every update_interval was itself part of why the tail never showed.
  • Rendered headline images are now cached for the life of a data refresh, so paging doesn't re-rasterise text every cycle.

Generated by Claude Code

The ticker was being pulled off screen mid-headline no matter how much
content was left to scroll.

Root cause: the plugin never declared dynamic-duration support. Its
config keeps the dynamic_duration block under `global`, but
BasePlugin.supports_dynamic_duration() reads a *top-level*
`dynamic_duration` key, so it saw nothing and returned False. The
display controller therefore treated the ticker as fixed-duration --
it never consulted is_cycle_complete() and cut the strip at a flat
wall-clock time (ScrollHelper's 60s placeholder on the first turn).

Implement the full hook family so the controller waits for the scroll
to actually finish: supports_dynamic_duration(), get_cycle_duration(),
get_dynamic_duration_cap() and reset_cycle_state(). get_cycle_duration()
carries overrun slack because a loaded Pi renders below the nominal
frame rate and ScrollHelper drops the missed pixels rather than catching
up; the cap includes that slack too, since the controller takes
min(cycle duration, cap) and a bare cap would clip it.

Add headline paging on top. Headlines are never abbreviated, so a full
feed set produces a strip minutes long -- more than any single turn can
show. Each turn now carries as many headlines as can finish scrolling in
the time available, and the next turn resumes at the first headline that
didn't fit. Nothing is drawn that can't be read to the end, and the tail
of a long list finally reaches the panel, replacing the old
rotate-one-headline-every-three-cycles behaviour that left it unreachable.

Also stop rewinding the page window on every data refresh, cache rendered
headline images for the life of a refresh, and only report a
scroll-derived display duration once a strip actually exists.

New global.headline_paging config block, enabled by default; the legacy
rotation path is kept for when it's disabled.

Verified with the core safety harness (PASS at all 8 panel sizes) and a
new test module covering page sizing, ordering, and the duration hooks.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CpxDq4HS7vgmMXi5ZC168G
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@ChuckBuilds, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 2 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 66ba0a8f-7563-4f63-a149-388b1b863f0b

📥 Commits

Reviewing files that changed from the base of the PR and between e1769a0 and 3c341dc.

📒 Files selected for processing (6)
  • plugins.json
  • plugins/news/README.md
  • plugins/news/config_schema.json
  • plugins/news/manager.py
  • plugins/news/manifest.json
  • plugins/news/test_news_ticker.py
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/news-ticker-headline-display-q1pyrc

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codacy-production

codacy-production Bot commented Jul 28, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 111 complexity

Metric Results
Complexity 111

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

PIL anti-aliases text by default, blending glyph edges into dim
partial-lit pixels. On a 1:1 LED matrix those read as blur rather than
as smoothing.

It bites hardest at sizes that miss the font's design grid. Press Start
2P is drawn on an 8px grid, so at size 8 or 16 it lands on the pixel
grid and stays crisp either way -- but at this plugin's default size of
12, a single headline picks up ~150 blended pixels. Measured across
sizes 8/10/12/16/20: 0/186/151/0/258 blended pixels with the default,
0 at every size with fontmode = "1".

Route every draw surface through a _pixel_draw() helper that sets
fontmode = "1", covering headlines, feed labels, separators and both
fallback screens. The throwaway surfaces used only to measure text go
through it too, so metrics can never disagree with what actually
renders. This matches the convention already used by ledmatrix-flights
and the sports scoreboards.

Vertical centring was checked and deliberately left alone: it centres on
the font bbox rather than on ink extents, which keeps the baseline
stable across headlines whether or not a given one has descenders.

Test module renamed test_headline_paging.py -> test_news_ticker.py now
that it covers more than paging, and extended with three crispness
tests. They assert zero blended pixels -- measured as pixels that are
neither background nor an exact fill colour, since the plugin draws
legitimately dim greys (feed label 150, fallback 220) that a brightness
threshold would wrongly flag. Each test was confirmed to fail with the
fix reverted.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CpxDq4HS7vgmMXi5ZC168G
@ChuckBuilds
ChuckBuilds merged commit 3f7c90e into main Jul 28, 2026
4 checks passed
@ChuckBuilds
ChuckBuilds deleted the claude/news-ticker-headline-display-q1pyrc branch July 28, 2026 22:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants