feat(scroll): show which scroll speeds are smooth on this panel (#710)

* feat(scroll): show which scroll speeds are smooth on this panel

The Vegas Scroll Speed slider now says what the panel will do with the
chosen speed and offers the nearest smooth ones to click. Backed by
scroll_config.speed_advice() and GET /api/v3/config/scroll-speed-advice,
which uses the refresh the display measured rather than the cap.

Also stops the default 50 px/s snapping to a stepped 48 px/s (2px every 5
refreshes, 24fps) on a 120Hz panel: the low-fps penalty in solve_crisp()
now loses to 60 or 40 px/s. 100Hz panels are unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(scroll): hint threw before its timer variables existed; count 25-30fps as stepped

The Vegas speed hint called refreshScrollSpeedHint() before the let
declarations it uses, so it never rendered (found on ledpi). And the
solver's low-fps penalty stopped at 25fps, which let a measured 125.7Hz
panel keep a 25.1fps 2px-every-5-refreshes scroll.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* test: add the scroll-speed-advice route to the /api/v3 URL map snapshot

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-01 14:15:22 -04:00
committed by GitHub
co-authored by Claude Sonnet 5.5
parent 4ddc3a3620
commit 16b566e14f
8 changed files with 285 additions and 4 deletions
+39
View File
@@ -13,6 +13,7 @@ from src.common.scroll_config import ( # noqa: E402
MAX_PIXELS_PER_FRAME,
crisp_ladder,
solve_crisp,
speed_advice,
MAX_PIXELS_PER_SECOND,
MIN_PIXELS_PER_SECOND,
ScrollSettings,
@@ -465,3 +466,41 @@ class TestFrameHoldIsReportedNotApplied:
display_manager=dm)
assert dm.calls == [], "configure() must not apply the hold itself"
assert settings.frame_hold == 4, "but it must report what to apply"
class TestSpeedAdvice:
def test_default_speed_on_a_120hz_panel_is_not_left_stepped(self):
"""50 px/s used to snap to 48 (2px every 5 refreshes, 24fps)."""
got = solve_crisp(50, 120)
assert got.steppiness == "smooth"
assert got.pixels_per_frame == 1
def test_unchanged_choices_on_a_100hz_panel(self):
assert solve_crisp(50, 100).pixels_per_second == pytest.approx(50.0)
assert solve_crisp(60, 100).pixels_per_second == pytest.approx(66.667, abs=0.01)
def test_smooth_exact_speed_needs_no_alternatives(self):
advice = speed_advice(60, 120)
assert advice["exact"] and advice["smooth"]
assert advice["alternatives"] == []
def test_off_ladder_speed_offers_the_nearest_smooth_ones(self):
advice = speed_advice(50, 120, 10, 200)
assert advice["applied"]["steppiness"] == "smooth"
offered = [a["pixels_per_second"] for a in advice["alternatives"]]
assert offered == [40.0, 60.0]
assert all(a["steppiness"] == "smooth" for a in advice["alternatives"])
def test_alternatives_stay_inside_the_requested_range(self):
advice = speed_advice(50, 120, 45, 200)
assert all(45 <= a["pixels_per_second"] <= 200 for a in advice["alternatives"])
def test_a_whole_number_near_the_panels_speed_counts_as_exact(self):
"""The UI sends 63 for a 62.9 px/s panel."""
assert speed_advice(63, 125.74)["exact"]
def test_a_measured_rate_does_not_let_a_stepped_speed_through(self):
"""125.74Hz: 50.3px/s is 2px every 5 refreshes at 25.1fps."""
got = solve_crisp(50, 125.74)
assert got.steppiness == "smooth"
assert got.pixels_per_frame == 1